stimeo-ui 0.8.0 → 0.10.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -0
  3. data/dist/controllers/alert_dialog_controller.js +17 -7
  4. data/dist/controllers/announcer_controller.js +1 -1
  5. data/dist/controllers/bulk_select_controller.js +139 -28
  6. data/dist/controllers/clipboard_controller.js +102 -20
  7. data/dist/controllers/collapsible_controller.js +1 -1
  8. data/dist/controllers/color_picker_controller.js +180 -43
  9. data/dist/controllers/command_palette_controller.js +15 -5
  10. data/dist/controllers/confirm_controller.js +15 -5
  11. data/dist/controllers/countdown_controller.js +1 -1
  12. data/dist/controllers/data_grid_controller.js +151 -25
  13. data/dist/controllers/dialog_controller.js +15 -5
  14. data/dist/controllers/direct_upload_controller.js +1 -5
  15. data/dist/controllers/drawer_controller.js +17 -7
  16. data/dist/controllers/editable_controller.js +83 -30
  17. data/dist/controllers/filter_controller.js +32 -1
  18. data/dist/controllers/focus_controller.js +45 -7
  19. data/dist/controllers/form_field_controller.js +1 -1
  20. data/dist/controllers/masonry_controller.js +129 -17
  21. data/dist/controllers/menubar_controller.js +2 -2
  22. data/dist/controllers/multi_select_controller.js +1 -1
  23. data/dist/controllers/otp_controller.js +29 -16
  24. data/dist/controllers/overflow_menu_controller.js +2 -2
  25. data/dist/controllers/password_reveal_controller.js +102 -3
  26. data/dist/controllers/password_strength_controller.js +287 -42
  27. data/dist/controllers/reset_before_cache_controller.js +51 -5
  28. data/dist/controllers/resizable_controller.js +128 -55
  29. data/dist/controllers/scroll_restore_controller.js +127 -27
  30. data/dist/controllers/scroll_visibility_controller.js +167 -14
  31. data/dist/controllers/sidebar_controller.js +15 -5
  32. data/dist/controllers/step_indicator_controller.js +0 -5
  33. data/dist/controllers/theme_controller.js +151 -40
  34. data/dist/controllers/toast_controller.js +2 -2
  35. data/dist/controllers/transition_controller.js +79 -15
  36. data/dist/index.js +1612 -529
  37. data/dist/positioning/index.js +84 -25
  38. data/lib/stimeo/ui/version.rb +1 -1
  39. metadata +2 -2
data/dist/index.js CHANGED
@@ -36,6 +36,9 @@ function isReservedArrowChord(event, allow = []) {
36
36
  if (!event.key.startsWith("Arrow")) return false;
37
37
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
38
38
  }
39
+ function hasModifierChord(event) {
40
+ return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
41
+ }
39
42
 
40
43
  // src/controllers/accordion_controller.ts
41
44
  var AccordionController = class extends Controller {
@@ -438,15 +441,25 @@ var FocusTrap = class {
438
441
  * content cannot be focused or reached by assistive technology, honoring the
439
442
  * `aria-modal="true"` contract. An element that was *already* `inert` is left
440
443
  * untracked so `#releaseBackground` does not wrongly clear it.
444
+ *
445
+ * The walk climbs from the container to `body` and inerts each ancestor's other
446
+ * children. Scanning only `body`'s children would skip the branch the container
447
+ * sits in — everything beside it inside that branch is background too, and a
448
+ * nested container is the ordinary case.
441
449
  */
442
450
  #isolateBackground() {
443
451
  const container = this.#getContainer();
444
452
  this.#inertedSiblings = [];
445
- for (const sibling of Array.from(document.body.children)) {
446
- if (!(sibling instanceof HTMLElement)) continue;
447
- if (sibling.contains(container) || sibling.inert) continue;
448
- sibling.inert = true;
449
- this.#inertedSiblings.push(sibling);
453
+ for (let node = container; node !== document.body; ) {
454
+ const parent = node.parentElement;
455
+ if (!parent) break;
456
+ for (const sibling of Array.from(parent.children)) {
457
+ if (!(sibling instanceof HTMLElement)) continue;
458
+ if (sibling === node || sibling.inert) continue;
459
+ sibling.inert = true;
460
+ this.#inertedSiblings.push(sibling);
461
+ }
462
+ node = parent;
450
463
  }
451
464
  }
452
465
  /** Reverts the `inert` flags applied by `#isolateBackground`. */
@@ -484,8 +497,8 @@ var AlertDialogController = class extends Controller {
484
497
  static actions = ["cancel", "confirm", "open"];
485
498
  static events = ["cancel", "confirm"];
486
499
  /**
487
- * Owns the modal side effects. Escape is routed through {@link cancel} so it
488
- * emits the same event as the cancel button (tagged `"escape"`); focus falls
500
+ * Owns the modal side effects. Escape takes the same cancel path as
501
+ * {@link cancel} and emits the same event, tagged `"escape"` instead of `"user"`; focus falls
489
502
  * back to the trigger when nothing was focused before opening.
490
503
  */
491
504
  #trap = new FocusTrap(() => this.dialogTarget, {
@@ -855,7 +868,7 @@ var AnnouncerController = class extends Controller {
855
868
  }
856
869
  /**
857
870
  * Arms `region`'s single pending timer. Callers reach here with the slot
858
- * already free — `#announce` releases it, and a fired timer clears its own
871
+ * already free — `#drain` releases it before writing, and a fired timer clears its own
859
872
  * entry below — so this does not cancel again.
860
873
  */
861
874
  #schedule(region, callback, delay) {
@@ -1664,72 +1677,156 @@ var BreadcrumbController = class extends Controller {
1664
1677
  return active instanceof HTMLElement && element.contains(active);
1665
1678
  }
1666
1679
  };
1680
+
1681
+ // src/utils/announce.ts
1682
+ function announce(message, options = {}) {
1683
+ const text = message.trim();
1684
+ if (text.length === 0) return;
1685
+ window.dispatchEvent(
1686
+ new CustomEvent("stimeo--announcer:announce", {
1687
+ detail: { message: text, assertive: options.assertive === true }
1688
+ })
1689
+ );
1690
+ }
1691
+ function fillTemplate(template, values) {
1692
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
1693
+ const replacement = values[name];
1694
+ return replacement === void 0 ? match : String(replacement);
1695
+ });
1696
+ }
1697
+
1698
+ // src/controllers/bulk_select_controller.ts
1667
1699
  var BulkSelectController = class extends Controller {
1668
1700
  static targets = ["all", "item", "bar", "count", "selectAllPages"];
1669
1701
  static values = {
1670
1702
  totalCount: { type: Number, default: 0 },
1671
- announce: { type: Boolean, default: true }
1703
+ announceText: { type: String, default: "" }
1672
1704
  };
1673
1705
  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. */
1706
+ static events = ["change", "reconcile"];
1707
+ /** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a
1708
+ * `connect()` over markup that already carries the attribute rehydrates the
1709
+ * mode — a morph, a Turbo Stream, a server that renders it back, or a restore
1710
+ * visit, whose cached snapshot carries the attribute too. */
1677
1711
  #allPagesMode = false;
1678
- /** Last emitted figures, so a recompute dispatches `change` only on real change. */
1712
+ /** Last emitted figures, so a recompute reports only on a real change. */
1679
1713
  #lastCount = -1;
1680
1714
  #lastAllPages = false;
1715
+ /** Collapses every signal from one DOM or Value update into one repair. */
1716
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileNow());
1681
1717
  /** Delegated `change` handler covering the select-all box and every row. */
1682
1718
  #onChange = (event) => {
1683
1719
  const target = event.target;
1684
1720
  if (!target) return;
1685
1721
  if (this.hasAllTarget && target === this.allTarget) {
1686
1722
  this.#applyAll();
1687
- } else if (target.matches('[data-stimeo--bulk-select-target="item"]')) {
1723
+ } else if (this.itemTargets.some((item) => item === target)) {
1688
1724
  this.#exitAllPages();
1689
- this.#recompute(true);
1725
+ this.#reportChange(this.#recompute());
1690
1726
  }
1691
1727
  };
1692
1728
  connect() {
1693
1729
  this.#allPagesMode = this.element.dataset.allPages === "true";
1730
+ this.#reconcile.activate();
1694
1731
  this.element.addEventListener("change", this.#onChange);
1695
- this.#recompute(false);
1732
+ this.#recompute();
1696
1733
  }
1697
1734
  disconnect() {
1698
1735
  this.element.removeEventListener("change", this.#onChange);
1736
+ this.#reconcile.cancel();
1737
+ }
1738
+ /** Repairs the figures for a row that arrived at runtime. */
1739
+ itemTargetConnected() {
1740
+ this.#reconcile.schedule();
1741
+ }
1742
+ /** Repairs the figures after a row leaves, so a removed selection stops counting. */
1743
+ itemTargetDisconnected() {
1744
+ this.#reconcile.schedule();
1745
+ }
1746
+ /** Reflects the current selection onto a select-all box added at runtime. */
1747
+ allTargetConnected() {
1748
+ this.#reconcile.schedule();
1749
+ }
1750
+ /** Repairs the figures after the select-all box leaves. */
1751
+ allTargetDisconnected() {
1752
+ this.#reconcile.schedule();
1753
+ }
1754
+ /** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */
1755
+ totalCountValueChanged() {
1756
+ if (!Number.isFinite(this.totalCountValue)) {
1757
+ this.totalCountValue = 0;
1758
+ return;
1759
+ }
1760
+ this.#reconcile.schedule();
1761
+ }
1762
+ /** Repaints so wording changed at runtime is used by the next announcement. */
1763
+ announceTextValueChanged() {
1764
+ this.#reconcile.schedule();
1699
1765
  }
1700
1766
  /** Clears every selection (rows + select-all) and exits all-pages mode. */
1701
1767
  clear() {
1702
- for (const item of this.#items) item.checked = false;
1768
+ for (const item of this.itemTargets) item.checked = false;
1703
1769
  if (this.hasAllTarget) {
1704
1770
  this.allTarget.checked = false;
1705
1771
  this.allTarget.indeterminate = false;
1706
1772
  }
1707
1773
  this.#exitAllPages();
1708
- this.#recompute(true);
1774
+ this.#reportChange(this.#recompute());
1709
1775
  }
1710
- /** Enters "select all across pages" mode (count shows `totalCount`). */
1776
+ /**
1777
+ * Enters "select all across pages" mode: the count shows `totalCount`, and every
1778
+ * row on this page is checked.
1779
+ *
1780
+ * The mode's claim is that the whole set is selected, so leaving a visible row
1781
+ * unchecked would put the page and the count in open disagreement.
1782
+ */
1711
1783
  selectAllPages() {
1712
1784
  this.#allPagesMode = true;
1713
- this.#recompute(true);
1785
+ this.#checkEveryRow();
1786
+ this.#reportChange(this.#recompute());
1787
+ }
1788
+ /** Marks every row on this page selected. */
1789
+ #checkEveryRow() {
1790
+ for (const item of this.itemTargets) item.checked = true;
1714
1791
  }
1715
1792
  /** Mirrors the select-all box to every row, then recomputes. */
1716
1793
  #applyAll() {
1717
1794
  if (!this.hasAllTarget) return;
1718
1795
  const { checked } = this.allTarget;
1719
- for (const item of this.#items) item.checked = checked;
1796
+ for (const item of this.itemTargets) item.checked = checked;
1720
1797
  this.#exitAllPages();
1721
- this.#recompute(true);
1798
+ this.#reportChange(this.#recompute());
1722
1799
  }
1723
1800
  #exitAllPages() {
1724
1801
  this.#allPagesMode = false;
1725
1802
  }
1803
+ /** Repairs the derived state after the page moved rows or a render input. */
1804
+ #reconcileNow() {
1805
+ if (this.#allPagesMode) this.#checkEveryRow();
1806
+ const detail = this.#recompute();
1807
+ if (!detail) return;
1808
+ this.dispatch("reconcile", { detail });
1809
+ this.#announce(detail);
1810
+ }
1811
+ /** Reports a selection the user moved. */
1812
+ #reportChange(detail) {
1813
+ if (!detail) return;
1814
+ this.dispatch("change", { detail });
1815
+ this.#announce(detail);
1816
+ }
1817
+ /** Hands the count to the shared announcer, worded by the consumer. */
1818
+ #announce(detail) {
1819
+ announce(fillTemplate(this.announceTextValue, { count: detail.count }));
1820
+ }
1726
1821
  /**
1727
1822
  * 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.
1823
+ * visibility from the current DOM. Returns the figures when the emitted count or
1824
+ * all-pages flag actually moved, and `null` when they did not.
1825
+ *
1826
+ * @stimeoRenderRoot
1730
1827
  */
1731
- #recompute(notify) {
1732
- const items = this.#items;
1828
+ #recompute() {
1829
+ const items = this.itemTargets;
1733
1830
  const total = items.length;
1734
1831
  const checked = items.filter((item) => item.checked).length;
1735
1832
  const allPages = this.#allPagesMode;
@@ -1740,8 +1837,10 @@ var BulkSelectController = class extends Controller {
1740
1837
  const count = allPages ? this.totalCountValue : checked;
1741
1838
  const show = allPages || checked > 0;
1742
1839
  if (this.hasBarTarget) {
1840
+ if (!show && this.hasAllTarget && this.barTarget.contains(document.activeElement)) {
1841
+ this.allTarget.focus();
1842
+ }
1743
1843
  this.barTarget.hidden = !show;
1744
- this.barTarget.setAttribute("aria-live", this.announceValue ? "polite" : "off");
1745
1844
  }
1746
1845
  if (this.hasCountTarget) this.countTarget.textContent = String(count);
1747
1846
  this.element.setAttribute("data-selected-count", String(checked));
@@ -1750,15 +1849,7 @@ var BulkSelectController = class extends Controller {
1750
1849
  const changed = count !== this.#lastCount || allPages !== this.#lastAllPages;
1751
1850
  this.#lastCount = count;
1752
1851
  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
- );
1852
+ return changed ? { count, allPages } : null;
1762
1853
  }
1763
1854
  };
1764
1855
 
@@ -2718,25 +2809,6 @@ function hits(elements, node) {
2718
2809
  function setAttributeIfChanged(element, name, value) {
2719
2810
  if (element.getAttribute(name) !== value) element.setAttribute(name, value);
2720
2811
  }
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
2812
  var CharacterCounterController = class _CharacterCounterController extends Controller {
2741
2813
  static targets = ["input", "output"];
2742
2814
  static values = {
@@ -3150,45 +3222,59 @@ function setDefaultAttribute(element, name, value) {
3150
3222
  }
3151
3223
 
3152
3224
  // src/controllers/clipboard_controller.ts
3225
+ var TRANSIENT_STATES = /* @__PURE__ */ new Set(["copied", "error"]);
3153
3226
  var ClipboardController = class extends Controller {
3154
3227
  static targets = ["source", "button", "feedback"];
3155
3228
  static values = {
3156
3229
  text: { type: String, default: "" },
3157
3230
  feedbackDuration: { type: Number, default: 2e3 },
3158
3231
  copiedLabel: { type: String, default: "Copied" },
3159
- errorLabel: { type: String, default: "Copy failed" }
3232
+ errorLabel: { type: String, default: "Copy failed" },
3233
+ announceCopiedText: { type: String, default: "" },
3234
+ announceErrorText: { type: String, default: "" }
3160
3235
  };
3161
3236
  static actions = ["copy"];
3162
3237
  static events = ["copy"];
3163
- /** Auto-clear timer for the completion notice; torn down on disconnect. */
3238
+ /**
3239
+ * The pending return to idle — the only timer this controller schedules, so
3240
+ * `clearAll()` is exactly "drop the auto-clear" and needs no id of its own.
3241
+ */
3164
3242
  #timers = new SafeTimeout();
3243
+ /** Returns the completion state to idle for the snapshot Turbo takes. */
3244
+ #beforeCache = new BeforeCacheReset(() => this.#rewind());
3165
3245
  /**
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.
3246
+ * Whether this connection is still live. `copy()` suspends on the Clipboard API,
3247
+ * and a teardown that lands while it is suspended must win: the continuation
3248
+ * would otherwise write to an element nobody owns and arm a timer past the
3249
+ * `clearAll()` that was supposed to be the last word.
3169
3250
  */
3170
- #resetTimerId = null;
3251
+ #connected = false;
3171
3252
  connect() {
3172
- setDefaultAttribute(this.element, "data-state", "idle");
3253
+ this.#connected = true;
3254
+ this.#adopt();
3255
+ this.#beforeCache.activate();
3173
3256
  }
3174
3257
  disconnect() {
3258
+ this.#connected = false;
3259
+ this.#beforeCache.deactivate();
3175
3260
  this.#timers.clearAll();
3176
3261
  }
3177
3262
  /**
3178
3263
  * 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.
3264
+ * (click). Dispatches `stimeo--clipboard:copy` with `{ success, text }` once per
3265
+ * completed attempt — including on failure — so consumers can react either way.
3266
+ * An attempt whose connection ended while it was in flight reports nothing.
3181
3267
  */
3182
3268
  async copy() {
3183
3269
  const text = this.#resolveText();
3184
3270
  let success = false;
3185
3271
  try {
3186
- if (!navigator.clipboard?.writeText) throw new Error("Clipboard API unavailable");
3187
3272
  await navigator.clipboard.writeText(text);
3188
3273
  success = true;
3189
3274
  } catch {
3190
3275
  success = false;
3191
3276
  }
3277
+ if (!this.#connected) return;
3192
3278
  this.#reportResult(success);
3193
3279
  this.dispatch("copy", { detail: { success, text } });
3194
3280
  }
@@ -3205,24 +3291,52 @@ var ClipboardController = class extends Controller {
3205
3291
  }
3206
3292
  return source.textContent ?? "";
3207
3293
  }
3208
- /** Reflects the result on `data-state`, announces it, and schedules a reset. */
3294
+ /**
3295
+ * Reads the current state back from the DOM.
3296
+ *
3297
+ * A `copied` or `error` found at connect time is this controller's own output
3298
+ * from a connection that is gone, and so is the timer that would have cleared it
3299
+ * — nothing else would ever return the element to `idle`. Any other authored
3300
+ * value belongs to the consumer and only a missing attribute takes the default.
3301
+ */
3302
+ #adopt() {
3303
+ if (this.#inTransientState()) {
3304
+ this.#reset();
3305
+ return;
3306
+ }
3307
+ setDefaultAttribute(this.element, "data-state", "idle");
3308
+ }
3309
+ /** Whether `data-state` currently holds one of the values this controller writes. */
3310
+ #inTransientState() {
3311
+ const state = this.element.getAttribute("data-state");
3312
+ return state !== null && TRANSIENT_STATES.has(state);
3313
+ }
3314
+ /**
3315
+ * Returns the completion state to idle for the snapshot Turbo is about to take,
3316
+ * so a page reached with the Back button does not report a copy that happened
3317
+ * before the navigation. Only a state this controller wrote is rewound — an
3318
+ * authored one is the consumer's and has to survive into the snapshot, exactly as
3319
+ * `connect()` leaves it alone. State only — no `copy` is dispatched, which would
3320
+ * claim a fresh copy ran.
3321
+ */
3322
+ #rewind() {
3323
+ if (!this.#inTransientState()) return;
3324
+ this.#timers.clearAll();
3325
+ this.#reset();
3326
+ }
3327
+ /** Reflects the result, announces it, and schedules the return to idle. */
3209
3328
  #reportResult(success) {
3210
3329
  this.element.setAttribute("data-state", success ? "copied" : "error");
3211
3330
  if (this.hasFeedbackTarget) {
3212
3331
  this.feedbackTarget.textContent = success ? this.copiedLabelValue : this.errorLabelValue;
3213
3332
  }
3214
- if (this.#resetTimerId !== null) {
3215
- this.#timers.clear(this.#resetTimerId);
3216
- this.#resetTimerId = null;
3217
- }
3333
+ announce(success ? this.announceCopiedTextValue : this.announceErrorTextValue);
3334
+ this.#timers.clearAll();
3218
3335
  if (this.feedbackDurationValue > 0) {
3219
- this.#resetTimerId = this.#timers.set(() => {
3220
- this.#resetTimerId = null;
3221
- this.#reset();
3222
- }, this.feedbackDurationValue);
3336
+ this.#timers.set(() => this.#reset(), this.feedbackDurationValue);
3223
3337
  }
3224
3338
  }
3225
- /** Returns to the idle state and clears the completion notice. */
3339
+ /** Returns to the idle state and empties the completion slot. */
3226
3340
  #reset() {
3227
3341
  this.element.setAttribute("data-state", "idle");
3228
3342
  if (this.hasFeedbackTarget) {
@@ -3396,7 +3510,7 @@ var CollapsibleController = class extends Controller {
3396
3510
  * `open` Value disagrees. An already-open region remains open without a
3397
3511
  * close/reopen cycle. The Value only seeds a genuinely fresh render where no
3398
3512
  * state attribute is present yet; any opening animation in that case belongs to
3399
- * the consumer's CSS. Mirrors `sidebar`'s `#restoreCollapsed`.
3513
+ * the consumer's CSS.
3400
3514
  */
3401
3515
  connect() {
3402
3516
  this.#connected = true;
@@ -3511,8 +3625,62 @@ function toFiniteNumber(raw) {
3511
3625
  return Number.isFinite(value) ? value : null;
3512
3626
  }
3513
3627
 
3628
+ // src/utils/owned_pointer_session.ts
3629
+ var OwnedPointerSession = class {
3630
+ pointerId;
3631
+ #owner;
3632
+ #handlers;
3633
+ #abort = new AbortController();
3634
+ #active = true;
3635
+ constructor(start, owner, handlers) {
3636
+ this.pointerId = start.pointerId;
3637
+ this.#owner = owner;
3638
+ this.#handlers = handlers;
3639
+ const { signal } = this.#abort;
3640
+ owner.ownerDocument.addEventListener("pointermove", this.#onMove, { signal });
3641
+ owner.ownerDocument.addEventListener("pointerup", this.#onEndEvent, { signal });
3642
+ owner.ownerDocument.addEventListener("pointercancel", this.#onEndEvent, { signal });
3643
+ owner.addEventListener("lostpointercapture", this.#onLostCapture, { signal });
3644
+ try {
3645
+ owner.setPointerCapture?.(this.pointerId);
3646
+ } catch {
3647
+ }
3648
+ }
3649
+ /** Whether this session still owns its pointer and listeners. */
3650
+ get active() {
3651
+ return this.#active;
3652
+ }
3653
+ /** Whether `event` belongs to the initiating pointer of the live session. */
3654
+ owns(event) {
3655
+ return this.#active && event.pointerId === this.pointerId;
3656
+ }
3657
+ /** Releases capture/listeners and invokes the end callback exactly once. */
3658
+ end() {
3659
+ if (!this.#active) return;
3660
+ this.#active = false;
3661
+ this.#abort.abort();
3662
+ try {
3663
+ this.#owner.releasePointerCapture?.(this.pointerId);
3664
+ } catch {
3665
+ }
3666
+ this.#handlers.end?.();
3667
+ }
3668
+ #onMove = (event) => {
3669
+ if (this.owns(event)) this.#handlers.move(event);
3670
+ };
3671
+ #onEndEvent = (event) => {
3672
+ if (this.owns(event)) this.end();
3673
+ };
3674
+ #onLostCapture = (event) => {
3675
+ const pointerId = event.pointerId;
3676
+ if (typeof pointerId === "number" && pointerId !== this.pointerId) return;
3677
+ this.end();
3678
+ };
3679
+ };
3680
+
3514
3681
  // src/controllers/color_picker_controller.ts
3515
3682
  var COLOR_PROPERTY = "--stimeo--color";
3683
+ var VALUE_TEXT_ATTRIBUTE = "data-value-text";
3516
3684
  var CHANNEL_RANGE = {
3517
3685
  hue: [0, 360],
3518
3686
  saturation: [0, 100],
@@ -3532,10 +3700,10 @@ var ColorPickerController = class extends Controller {
3532
3700
  get #mirrored() {
3533
3701
  return this.logicalTrackValue && isRtl(this.element);
3534
3702
  }
3535
- /** The current color in the editing model. */
3703
+ /** The current color in the editing model; its alpha is 100 while `alpha` is off. */
3536
3704
  #color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
3537
- /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
3538
- #dragAbort = null;
3705
+ /** The pointer that owns the live drag, with the slider whose geometry maps it. */
3706
+ #drag = null;
3539
3707
  /** Color the last repaint settled on, so a configuration-driven move is reported once. */
3540
3708
  #committedHex = null;
3541
3709
  /**
@@ -3546,25 +3714,48 @@ var ColorPickerController = class extends Controller {
3546
3714
  /** Seeds the model from the initial hex value and renders every surface. */
3547
3715
  connect() {
3548
3716
  this.#repaint.activate();
3549
- const parsed = hexToHsla(this.valueValue);
3550
- if (parsed) this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
3717
+ this.#adoptValue();
3551
3718
  this.#render();
3552
3719
  }
3553
3720
  /** Cancels any active pointer drag so document listeners never leak. */
3554
3721
  disconnect() {
3555
3722
  this.#repaint.cancel();
3556
- this.#dragAbort?.abort();
3557
- this.#dragAbort = null;
3723
+ this.#endDrag();
3558
3724
  }
3559
3725
  /** Repaints when application code (or a Turbo morph) changes `alpha` at runtime. */
3560
3726
  alphaValueChanged() {
3561
3727
  this.#repaint.schedule();
3562
3728
  }
3729
+ /** Adopts a color application code (or a Turbo morph) put in `value` at runtime. */
3730
+ valueValueChanged() {
3731
+ if (this.valueValue === this.#committedHex) return;
3732
+ this.#repaint.schedule();
3733
+ }
3734
+ /** Hydrates a channel slider inserted or replaced at runtime. */
3735
+ sliderTargetConnected(slider) {
3736
+ this.#renderSlider(slider);
3737
+ }
3738
+ /** Ends a gesture whose geometry target disappeared or ceased being a target. */
3739
+ sliderTargetDisconnected(slider) {
3740
+ if (this.#drag?.slider === slider) this.#endDrag();
3741
+ }
3742
+ /** Fills a hex input inserted or replaced at runtime with the current color. */
3743
+ hexTargetConnected(hex) {
3744
+ this.#mirrorColor(hex, this.#hexString());
3745
+ }
3746
+ /** Fills a form field inserted or replaced at runtime with the current color. */
3747
+ fieldTargetConnected(field) {
3748
+ this.#mirrorColor(field, this.#hexString());
3749
+ }
3750
+ /** Publishes the current color on a preview inserted or replaced at runtime. */
3751
+ previewTargetConnected(preview) {
3752
+ this.#publishColor(preview, this.#hexString());
3753
+ }
3563
3754
  /** Keyboard stepping on the focused channel slider (APG Slider model). */
3564
3755
  onKeydown(event) {
3565
3756
  if (isReservedArrowChord(event)) return;
3566
3757
  const slider = event.currentTarget;
3567
- const channel = this.#channelOf(slider);
3758
+ const channel = this.#editableChannel(slider);
3568
3759
  if (!channel) return;
3569
3760
  const [min, max] = this.#rangeOf(slider, channel);
3570
3761
  const value = this.#color[channel];
@@ -3596,34 +3787,36 @@ var ColorPickerController = class extends Controller {
3596
3787
  event.preventDefault();
3597
3788
  this.#setChannel(channel, next, min, max);
3598
3789
  }
3599
- /** Begins a pointer drag on a channel slider and tracks movement. */
3790
+ /** Begins a primary-button drag on a channel slider, owned by its own pointer. */
3600
3791
  onPointerDown(event) {
3792
+ if (event.button !== 0 || this.#drag) return;
3601
3793
  const slider = event.currentTarget;
3602
- const channel = this.#channelOf(slider);
3794
+ const channel = this.#editableChannel(slider);
3603
3795
  if (!channel) return;
3604
- event.preventDefault();
3605
- slider.focus();
3606
3796
  const [min, max] = this.#rangeOf(slider, channel);
3607
3797
  const mirrored = this.#mirrored;
3608
3798
  const update = (clientX) => {
3609
3799
  const rect = slider.getBoundingClientRect();
3610
- if (rect.width === 0) return;
3800
+ if (rect.width === 0) return false;
3611
3801
  const offset = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
3612
3802
  const fraction = mirrored ? 1 - offset : offset;
3613
3803
  this.#setChannel(channel, min + fraction * (max - min), min, max);
3804
+ return true;
3614
3805
  };
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 });
3806
+ if (!update(event.clientX)) return;
3807
+ event.preventDefault();
3808
+ slider.focus();
3809
+ const drag = { pointer: null, slider };
3810
+ drag.pointer = new OwnedPointerSession(event, slider, {
3811
+ move: (move) => {
3812
+ if (slider.isConnected) update(move.clientX);
3813
+ else this.#endDrag();
3814
+ },
3815
+ end: () => {
3816
+ if (this.#drag === drag) this.#drag = null;
3817
+ }
3818
+ });
3819
+ this.#drag = drag;
3627
3820
  }
3628
3821
  /** Parses the hex input on confirm and syncs every channel + surface. */
3629
3822
  onHexInput() {
@@ -3633,9 +3826,22 @@ var ColorPickerController = class extends Controller {
3633
3826
  this.hexTarget.value = this.#hexString();
3634
3827
  return;
3635
3828
  }
3636
- this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
3829
+ this.#color = this.#opaqueUnlessEnabled(parsed);
3637
3830
  this.#commitColor();
3638
3831
  }
3832
+ /** Replaces the model with the color `value` names, leaving an unparsable one alone. */
3833
+ #adoptValue() {
3834
+ const parsed = hexToHsla(this.valueValue);
3835
+ if (parsed) this.#color = this.#opaqueUnlessEnabled(parsed);
3836
+ }
3837
+ /**
3838
+ * The model a parsed color implies: alpha only survives while its channel is
3839
+ * enabled, because `hexString()` would otherwise emit `#RRGGBB` while `change`
3840
+ * reported `rgba.a < 1`.
3841
+ */
3842
+ #opaqueUnlessEnabled(parsed) {
3843
+ return this.alphaValue ? parsed : { ...parsed, alpha: 100 };
3844
+ }
3639
3845
  /** Clamps and snaps one channel to an integer, then re-renders + emits change. */
3640
3846
  #setChannel(channel, raw, min, max) {
3641
3847
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
@@ -3654,32 +3860,57 @@ var ColorPickerController = class extends Controller {
3654
3860
  }
3655
3861
  }
3656
3862
  /**
3657
- * Reflects the model onto sliders, the hex input, preview, and form field.
3863
+ * Reflects the model onto sliders, the hex input, preview, form field, and the
3864
+ * `value` Value it serializes into.
3658
3865
  *
3659
3866
  * @stimeoRenderRoot
3660
3867
  */
3661
3868
  #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
- }
3869
+ for (const slider of this.sliderTargets) this.#renderSlider(slider);
3669
3870
  const hex = this.#hexString();
3670
3871
  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);
3872
+ if (this.valueValue !== hex) this.valueValue = hex;
3873
+ if (this.hasHexTarget) this.#mirrorColor(this.hexTarget, hex);
3874
+ for (const field of this.fieldTargets) this.#mirrorColor(field, hex);
3875
+ for (const preview of this.previewTargets) this.#publishColor(preview, hex);
3876
+ this.#publishColor(this.element, hex);
3877
+ }
3878
+ /** Writes one slider's announced range, value, and value text, skipping equal ones. */
3879
+ #renderSlider(slider) {
3880
+ const channel = this.#channelOf(slider);
3881
+ if (!channel) return;
3882
+ const [min, max] = this.#rangeOf(slider, channel);
3883
+ const value = this.#color[channel];
3884
+ const attributes = {
3885
+ "aria-valuemin": String(min),
3886
+ "aria-valuemax": String(max),
3887
+ "aria-valuenow": String(value),
3888
+ "aria-valuetext": this.#valueText(slider, channel, value)
3889
+ };
3890
+ for (const [name, next] of Object.entries(attributes)) {
3891
+ if (slider.getAttribute(name) !== next) slider.setAttribute(name, next);
3892
+ }
3893
+ }
3894
+ /** Mirrors the color into an input, leaving an already-equal value untouched. */
3895
+ #mirrorColor(input, hex) {
3896
+ if (input.value !== hex) input.value = hex;
3897
+ }
3898
+ /** Publishes the color as the consumer's CSS hook, skipping an equal value. */
3899
+ #publishColor(element, hex) {
3900
+ if (element.style.getPropertyValue(COLOR_PROPERTY) !== hex) {
3901
+ element.style.setProperty(COLOR_PROPERTY, hex);
3902
+ }
3675
3903
  }
3676
3904
  /**
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.
3905
+ * Repaints after a declarative input changed at runtime and reports a color this
3906
+ * controller settled on. Disabling alpha drops it from the model and an outside
3907
+ * `value` names another color, so the committed color can move without a user
3908
+ * edit; `change` stays reserved for the picker's own actions.
3680
3909
  */
3681
3910
  #reconcileColor() {
3682
3911
  const previous = this.#committedHex;
3912
+ if (previous !== null && this.valueValue !== previous) this.#adoptValue();
3913
+ if (!this.alphaValue) this.#color.alpha = 100;
3683
3914
  this.#render();
3684
3915
  if (previous !== null && this.#committedHex !== previous) {
3685
3916
  this.dispatch("reconcile", { detail: this.#settledDetail() });
@@ -3700,7 +3931,27 @@ var ColorPickerController = class extends Controller {
3700
3931
  /** Reads a slider's `data-channel`, if it is a known channel. */
3701
3932
  #channelOf(slider) {
3702
3933
  const channel = slider.getAttribute("data-channel");
3703
- return channel && channel in CHANNEL_RANGE ? channel : null;
3934
+ return channel && Object.hasOwn(CHANNEL_RANGE, channel) ? channel : null;
3935
+ }
3936
+ /**
3937
+ * The channel a slider edits, or null when this picker edits none through it. An
3938
+ * alpha slider authored while `alpha` is off edits nothing: moving it would leave
3939
+ * the model translucent behind an opaque `#RRGGBB`.
3940
+ */
3941
+ #editableChannel(slider) {
3942
+ const channel = this.#channelOf(slider);
3943
+ return channel === "alpha" && !this.alphaValue ? null : channel;
3944
+ }
3945
+ /** The channel's announced text: the slider's template, or the built-in English. */
3946
+ #valueText(slider, channel, value) {
3947
+ const template = slider.getAttribute(VALUE_TEXT_ATTRIBUTE);
3948
+ return template ? template.replaceAll("{value}", String(value)) : defaultValueText(channel, value);
3949
+ }
3950
+ /** Ends the live drag so no further movement of that pointer reaches the model. */
3951
+ #endDrag() {
3952
+ const drag = this.#drag;
3953
+ this.#drag = null;
3954
+ drag?.pointer?.end();
3704
3955
  }
3705
3956
  /**
3706
3957
  * A slider's `[min, max]` from aria-valuemin/max, falling back per channel.
@@ -3716,7 +3967,7 @@ var ColorPickerController = class extends Controller {
3716
3967
  ];
3717
3968
  }
3718
3969
  };
3719
- function valueText(channel, value) {
3970
+ function defaultValueText(channel, value) {
3720
3971
  const label = channel.charAt(0).toUpperCase() + channel.slice(1);
3721
3972
  const unit = channel === "hue" ? "degrees" : "percent";
3722
3973
  return `${label} ${value} ${unit}`;
@@ -5183,7 +5434,7 @@ var CountdownController = class extends Controller {
5183
5434
  #pausedAmount = 0;
5184
5435
  /**
5185
5436
  * The amount the slots are currently showing, floored to the second they render.
5186
- * It lags {@link currentAmount} by up to one tick, and it — not the live reading —
5437
+ * It lags {@link #currentAmount} by up to one tick, and it — not the live reading —
5187
5438
  * is what a pause has to preserve: storing the fraction behind the display instead
5188
5439
  * makes the first tick after a resume step by two units.
5189
5440
  */
@@ -5691,6 +5942,25 @@ function round(value, precision) {
5691
5942
  if (!Number.isFinite(rounded)) return value;
5692
5943
  return rounded === 0 ? 0 : rounded;
5693
5944
  }
5945
+
5946
+ // src/utils/interactive_host.ts
5947
+ var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
5948
+ function isInteractiveHost(element) {
5949
+ if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
5950
+ let current = element;
5951
+ while (current) {
5952
+ const raw = current.getAttribute("contenteditable");
5953
+ if (raw !== null) {
5954
+ const value = raw.trim().toLowerCase();
5955
+ if (value === "false") return false;
5956
+ if (value === "" || value === "true" || value === "plaintext-only") return true;
5957
+ }
5958
+ current = current.parentElement;
5959
+ }
5960
+ return false;
5961
+ }
5962
+
5963
+ // src/controllers/data_grid_controller.ts
5694
5964
  var SORT_CYCLE = ["none", "ascending", "descending"];
5695
5965
  function nextSortDirection(current) {
5696
5966
  const index = SORT_CYCLE.indexOf(current);
@@ -5704,8 +5974,15 @@ var DataGridController = class extends Controller {
5704
5974
  };
5705
5975
  static actions = ["onKeydown", "sort", "toggleSelect"];
5706
5976
  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;
5977
+ /**
5978
+ * Collapses the per-element target callbacks of one DOM mutation into a single
5979
+ * baseline pass, and refuses to run before `connect()` or after `disconnect()`.
5980
+ *
5981
+ * Stimulus reports every target one at a time, so an ungated pass would re-walk
5982
+ * the whole grid once per authored cell on mount and once per streamed cell
5983
+ * afterwards — quadratic in the cell count both times.
5984
+ */
5985
+ #reconcile = new MicrotaskCoalescer(() => this.#restoreBaseline());
5709
5986
  /**
5710
5987
  * Establishes a single tab stop across all navigable cells/headers and brings
5711
5988
  * the rows to their baseline.
@@ -5716,15 +5993,30 @@ var DataGridController = class extends Controller {
5716
5993
  * attribute, so the Value callback does not fire a second time.
5717
5994
  */
5718
5995
  connect() {
5996
+ this.#restoreBaseline();
5997
+ this.#reconcile.activate();
5998
+ }
5999
+ /** Closes the reconcile window so a queued pass cannot run against a detached tree. */
6000
+ disconnect() {
6001
+ this.#reconcile.cancel();
6002
+ }
6003
+ /**
6004
+ * Rebuilds both DOM-owned baselines from the live grid: exactly one navigable
6005
+ * cell is in the Tab sequence, and every selectable row carries an explicit
6006
+ * `aria-selected`.
6007
+ *
6008
+ * The tab stop keeps whichever cell already holds it, so a rebuild triggered by
6009
+ * an unrelated row arriving does not throw the user's position away; only when
6010
+ * no cell holds it — the grid is fresh, or the holder was removed — does the
6011
+ * first navigable cell take over. Without that fallback a grid whose active row
6012
+ * is removed keeps every cell at `-1` and drops out of the Tab sequence
6013
+ * entirely.
6014
+ */
6015
+ #restoreBaseline() {
5719
6016
  const cells = this.#navigableCells();
5720
6017
  const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
5721
- this.#setActiveCell(active, { focus: false });
6018
+ if (active) this.#setActiveCell(active, { focus: false }, cells);
5722
6019
  this.#normalizeSelection();
5723
- this.#connected = true;
5724
- }
5725
- /** Reopens the row callback for the next mount. */
5726
- disconnect() {
5727
- this.#connected = false;
5728
6020
  }
5729
6021
  /**
5730
6022
  * Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
@@ -5735,16 +6027,25 @@ var DataGridController = class extends Controller {
5735
6027
  this.#syncSelectable();
5736
6028
  this.#normalizeSelection();
5737
6029
  }
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
- */
6030
+ /** Re-establishes the baselines for a row added after connect. */
5745
6031
  rowTargetConnected() {
5746
- if (!this.#connected) return;
5747
- this.#normalizeSelection();
6032
+ this.#reconcile.schedule();
6033
+ }
6034
+ /** Re-establishes the tab stop when a cell joins the grid after connect. */
6035
+ cellTargetConnected() {
6036
+ this.#reconcile.schedule();
6037
+ }
6038
+ /** Re-establishes the tab stop when a cell leaves the grid. */
6039
+ cellTargetDisconnected() {
6040
+ this.#reconcile.schedule();
6041
+ }
6042
+ /** Re-establishes the tab stop when a header joins the grid after connect. */
6043
+ columnHeaderTargetConnected() {
6044
+ this.#reconcile.schedule();
6045
+ }
6046
+ /** Re-establishes the tab stop when a header leaves the grid. */
6047
+ columnHeaderTargetDisconnected() {
6048
+ this.#reconcile.schedule();
5748
6049
  }
5749
6050
  /**
5750
6051
  * Brings the authored rows to the shape the APG requires, without changing
@@ -5789,6 +6090,9 @@ var DataGridController = class extends Controller {
5789
6090
  sort(event) {
5790
6091
  const header = event.currentTarget;
5791
6092
  if (!this.columnHeaderTargets.includes(header)) return;
6093
+ if (event.defaultPrevented) return;
6094
+ const control = this.#claimingControl(event, header);
6095
+ if (control && !(control instanceof HTMLButtonElement)) return;
5792
6096
  const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
5793
6097
  for (const other of this.columnHeaderTargets) {
5794
6098
  other.setAttribute("aria-sort", other === header ? direction : "none");
@@ -5799,14 +6103,19 @@ var DataGridController = class extends Controller {
5799
6103
  /** Toggles selection of the row owning the event target. Bound optionally. */
5800
6104
  toggleSelect(event) {
5801
6105
  if (this.selectionValue === "none") return;
5802
- const row = event.currentTarget.closest("[role='row']");
6106
+ if (event.defaultPrevented) return;
6107
+ const host = event.currentTarget;
6108
+ if (this.#claimedByDescendant(event, host)) return;
6109
+ const row = host.closest("[role='row']");
5803
6110
  if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
5804
6111
  }
5805
6112
  /** Grid navigation + sort/select activation. Bound to cells and headers. */
5806
6113
  onKeydown(event) {
5807
6114
  if (event.defaultPrevented) return;
5808
6115
  if (isReservedArrowChord(event)) return;
6116
+ if (event.isComposing) return;
5809
6117
  const cell = event.currentTarget;
6118
+ if (this.#claimedByDescendant(event, cell)) return;
5810
6119
  const matrix = this.#matrix();
5811
6120
  const position = this.#locate(matrix, cell);
5812
6121
  if (!position) return;
@@ -5842,9 +6151,35 @@ var DataGridController = class extends Controller {
5842
6151
  }
5843
6152
  if (target) {
5844
6153
  event.preventDefault();
5845
- this.#setActiveCell(target, { focus: true });
6154
+ this.#setActiveCell(target, { focus: true }, matrix.flat());
5846
6155
  }
5847
6156
  }
6157
+ /**
6158
+ * Whether the event was addressed to a control inside `host` rather than to the
6159
+ * grid.
6160
+ *
6161
+ * Cells and headers hold consumer markup, and APG's grid pattern expects that
6162
+ * markup to include working controls — a row action button, an inline editor.
6163
+ * Those own their own keystrokes and clicks, so the grid stands down entirely
6164
+ * rather than acting in parallel. An editable host (its `contenteditable` state
6165
+ * is inherited, so the walk is explicit) counts the same way.
6166
+ */
6167
+ #claimedByDescendant(event, host) {
6168
+ return this.#claimingControl(event, host) !== null;
6169
+ }
6170
+ /**
6171
+ * The nested control this event belongs to, or `null` when the host owns it.
6172
+ *
6173
+ * Naming the control, rather than answering yes or no, is what lets the click
6174
+ * path treat a sortable header's `<button>` as the activation it is while every
6175
+ * other control still takes the event away.
6176
+ */
6177
+ #claimingControl(event, host) {
6178
+ const source = event.target;
6179
+ const control = source.closest(INTERACTIVE_HOST_SELECTOR);
6180
+ if (control && host.contains(control)) return control;
6181
+ return isInteractiveHost(source) ? source : null;
6182
+ }
5848
6183
  /** Performs a header's sort or a cell row's selection toggle on activation. */
5849
6184
  #activate(cell) {
5850
6185
  if (this.columnHeaderTargets.includes(cell)) {
@@ -5855,7 +6190,7 @@ var DataGridController = class extends Controller {
5855
6190
  const row = cell.closest("[role='row']");
5856
6191
  if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
5857
6192
  }
5858
- /** Shared sort logic for both click and keyboard activation. */
6193
+ /** Cycles a header's sort on keyboard activation and emits `sort`. */
5859
6194
  #cycleSort(header) {
5860
6195
  const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
5861
6196
  for (const other of this.columnHeaderTargets) {
@@ -5875,11 +6210,22 @@ var DataGridController = class extends Controller {
5875
6210
  const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
5876
6211
  this.dispatch("selectionchange", { detail: { rows } });
5877
6212
  }
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;
6213
+ /**
6214
+ * Makes `cell` the single tabbable cell (roving) and optionally focuses it.
6215
+ *
6216
+ * `cells` lets a caller that already walked the grid hand its collection over,
6217
+ * so one keystroke rebuilds the matrix once instead of twice. The write is
6218
+ * skipped where the attribute already holds the wanted value — comparing the
6219
+ * attribute rather than the IDL property, because a cell with no `tabindex` at
6220
+ * all reports `-1` and would then never receive the attribute it needs to be
6221
+ * focusable.
6222
+ */
6223
+ #setActiveCell(cell, { focus }, cells) {
6224
+ for (const candidate of cells ?? this.#navigableCells()) {
6225
+ const wanted = candidate === cell ? "0" : "-1";
6226
+ if (candidate.getAttribute("tabindex") !== wanted) {
6227
+ candidate.setAttribute("tabindex", wanted);
6228
+ }
5883
6229
  }
5884
6230
  if (focus) cell.focus();
5885
6231
  }
@@ -6612,10 +6958,6 @@ var DirectUploadController = class extends Controller {
6612
6958
  this.#rows.delete(id);
6613
6959
  }
6614
6960
  }
6615
- /**
6616
- * Returns the widget to its pre-upload state just before Turbo caches the
6617
- * page, so the snapshot never replays rows for uploads that cannot resume.
6618
- */
6619
6961
  /**
6620
6962
  * Rewinds for the snapshot and reports what that discarded. An upload in flight
6621
6963
  * cannot survive the navigation, so a consumer mirroring the rows would keep a
@@ -6654,7 +6996,7 @@ var DirectUploadController = class extends Controller {
6654
6996
  #detail(event) {
6655
6997
  return event.detail ?? {};
6656
6998
  }
6657
- /** The file name every ActiveStorage `direct-upload:*` event carries. */
6999
+ /** The event's file name, or `""` when it carries none. */
6658
7000
  #name(detail) {
6659
7001
  return detail.file?.name ?? "";
6660
7002
  }
@@ -7028,8 +7370,8 @@ var DrawerController = class extends Controller {
7028
7370
  * rather than being re-derived from the declarative `open` Value (which would
7029
7371
  * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
7030
7372
  * render. We normalize to a clean closed baseline first so {@link open} runs its
7031
- * full reveal + trap activation — the {@link FocusTrap} is a fresh instance
7032
- * after a reconnect and must be re-activated.
7373
+ * full reveal + trap activation — the {@link FocusTrap} is inactive after a
7374
+ * disconnect and must be re-activated.
7033
7375
  */
7034
7376
  connect() {
7035
7377
  this.#connected = true;
@@ -7271,33 +7613,64 @@ var EditableController = class extends Controller {
7271
7613
  static values = {
7272
7614
  submitOnBlur: { type: Boolean, default: true }
7273
7615
  };
7274
- static actions = ["edit", "onBlur", "onDisplayKeydown", "onKeydown"];
7616
+ static actions = ["cancel", "edit", "onDisplayKeydown", "onKeydown", "revert", "save"];
7275
7617
  static events = ["cancel", "change"];
7276
7618
  /** The value captured when edit mode began, used to detect real changes. */
7277
7619
  #previousValue = "";
7620
+ /**
7621
+ * The value the last save replaced, or `null` when there is nothing to undo.
7622
+ * Cleared on connect: a value from before a page restore is unrecoverable, so
7623
+ * `revert()` must not resurrect one.
7624
+ */
7625
+ #revertValue = null;
7278
7626
  /**
7279
7627
  * Owns IME lifecycle state for the edit surface, so a keydown that belongs to
7280
7628
  * a composition (cancel or confirm) is never treated as an edit command.
7281
7629
  */
7282
7630
  #composition = new CompositionTracker();
7631
+ /**
7632
+ * Watches focus leaving the editor from wherever it currently sits.
7633
+ *
7634
+ * `focusout` bubbles where `blur` does not, so one listener on the root sees
7635
+ * every departure — including one from a Save or Cancel button the consumer
7636
+ * placed beside the input. Binding the input alone would make the promise
7637
+ * "saves wherever focus moved" true only for focus that leaves the input
7638
+ * itself, and tabbing straight past an inner button would strand the editor
7639
+ * open.
7640
+ */
7641
+ #onFocusOut = (event) => {
7642
+ const from = event.target;
7643
+ if (this.hasDisplayTarget && from instanceof Node && this.displayTarget.contains(from)) return;
7644
+ const next = event.relatedTarget;
7645
+ if (next instanceof Node && this.element.contains(next)) return;
7646
+ if (this.submitOnBlurValue) this.#commit(false);
7647
+ };
7283
7648
  /** Establishes the initial display mode (display shown, input hidden). */
7284
7649
  connect() {
7285
7650
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
7651
+ this.element.addEventListener("focusout", this.#onFocusOut);
7652
+ this.#revertValue = null;
7286
7653
  this.#setMode("display");
7287
7654
  }
7288
- /** Releases the composition listeners so nothing outlives the element. */
7655
+ /** Releases the composition and focus listeners so nothing outlives the element. */
7289
7656
  disconnect() {
7290
7657
  this.#composition.disconnect();
7658
+ this.element.removeEventListener("focusout", this.#onFocusOut);
7291
7659
  }
7292
7660
  /** Tracks an input added initially or after connect (e.g. a Turbo swap). */
7293
7661
  inputTargetConnected(input) {
7294
7662
  this.#composition.observe(input);
7663
+ this.#applyMode();
7295
7664
  }
7296
7665
  /** Removes composition listeners when the active input is replaced or removed. */
7297
7666
  inputTargetDisconnected(input) {
7298
7667
  this.#composition.unobserve(input);
7299
7668
  }
7300
- /** Enters edit mode: seeds the input from the display text, focuses, selects. */
7669
+ /** Re-hides or re-shows a display element that arrived after the mode was set. */
7670
+ displayTargetConnected() {
7671
+ this.#applyMode();
7672
+ }
7673
+ /** Enters edit mode: seeds the input from the declared value, focuses, selects. */
7301
7674
  edit() {
7302
7675
  if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
7303
7676
  this.#previousValue = this.#currentValue;
@@ -7306,6 +7679,27 @@ var EditableController = class extends Controller {
7306
7679
  this.inputTarget.focus();
7307
7680
  this.inputTarget.select();
7308
7681
  }
7682
+ /** Commits the edit and returns focus to the display element. */
7683
+ save() {
7684
+ this.#commit(true);
7685
+ }
7686
+ /** Discards edits, returns to display mode, and dispatches `cancel`. */
7687
+ cancel() {
7688
+ if (!this.#isEditing) return;
7689
+ this.#setMode("display");
7690
+ if (this.hasDisplayTarget) this.displayTarget.focus();
7691
+ this.dispatch("cancel", { detail: {} });
7692
+ }
7693
+ /**
7694
+ * Puts back the value the last save replaced — for a consumer whose server
7695
+ * rejected it. Silent by design: a `change` here would re-enter the same
7696
+ * handler that asked for the undo. One save, one undo.
7697
+ */
7698
+ revert() {
7699
+ if (this.#revertValue === null || this.#isEditing) return;
7700
+ this.#writeValue(this.#revertValue);
7701
+ this.#revertValue = null;
7702
+ }
7309
7703
  /** Adds `F2` as an editing entry point alongside the button's native activation. */
7310
7704
  onDisplayKeydown(event) {
7311
7705
  if (event.key === "F2") {
@@ -7319,56 +7713,57 @@ var EditableController = class extends Controller {
7319
7713
  if (event.key === "Escape") {
7320
7714
  if (event.defaultPrevented) return;
7321
7715
  event.preventDefault();
7322
- this.#cancel();
7716
+ this.cancel();
7323
7717
  return;
7324
7718
  }
7325
7719
  if (event.key === "Enter") {
7326
7720
  if (event.defaultPrevented) return;
7327
7721
  if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
7328
7722
  event.preventDefault();
7329
- this.#save(true);
7723
+ this.#commit(true);
7330
7724
  }
7331
7725
  }
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
7726
  /**
7338
- * Returns to display mode, reflecting the input into the display text and
7339
- * dispatching `change` when the value differs from where editing began.
7727
+ * Returns to display mode, storing the input's value and dispatching `change`
7728
+ * when it differs from where editing began.
7340
7729
  *
7341
7730
  * @param restoreFocus - Move focus back to the display element (explicit
7342
- * keyboard commit) rather than honoring the user's new focus target (blur).
7731
+ * commit) rather than honoring the user's new focus target (blur).
7343
7732
  */
7344
- #save(restoreFocus) {
7345
- if (!this.#isEditing) return;
7346
- const value = this.inputTarget.value;
7733
+ #commit(restoreFocus) {
7734
+ if (!this.#isEditing || !this.hasInputTarget) return;
7735
+ const value = this.inputTarget.value.trim();
7347
7736
  const previous = this.#previousValue;
7348
- this.displayTarget.textContent = value;
7737
+ this.#writeValue(value);
7349
7738
  this.#setMode("display");
7350
- if (restoreFocus) this.displayTarget.focus();
7739
+ if (restoreFocus && this.hasDisplayTarget) this.displayTarget.focus();
7351
7740
  if (value !== previous) {
7741
+ this.#revertValue = previous;
7352
7742
  this.dispatch("change", { detail: { value, previous } });
7353
7743
  }
7354
7744
  }
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: {} });
7745
+ /** Stores `value` wherever the display element declares it. */
7746
+ #writeValue(value) {
7747
+ if (!this.hasDisplayTarget) return;
7748
+ const display = this.displayTarget;
7749
+ if (display.hasAttribute("data-value")) display.dataset.value = value;
7750
+ else display.textContent = value;
7361
7751
  }
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";
7752
+ /** Derives both elements' visibility from the mode currently in the DOM. */
7753
+ #applyMode() {
7754
+ const editing = this.#isEditing;
7366
7755
  if (this.hasDisplayTarget) this.displayTarget.hidden = editing;
7367
7756
  if (this.hasInputTarget) this.inputTarget.hidden = !editing;
7368
7757
  }
7369
- /** Current display text, trimmed — the value shown when not editing. */
7758
+ /** Records the mode, then brings both elements in line with it. */
7759
+ #setMode(mode) {
7760
+ this.element.dataset.mode = mode;
7761
+ this.#applyMode();
7762
+ }
7763
+ /** The value the display element declares, trimmed. */
7370
7764
  get #currentValue() {
7371
- return (this.displayTarget.textContent ?? "").trim();
7765
+ const display = this.displayTarget;
7766
+ return (display.dataset.value ?? display.textContent ?? "").trim();
7372
7767
  }
7373
7768
  /** Whether the editing control is a multi-line `<textarea>`. */
7374
7769
  get #isMultiline() {
@@ -7980,15 +8375,46 @@ var FilterController = class extends Controller {
7980
8375
  #onChange = () => {
7981
8376
  this.apply();
7982
8377
  };
8378
+ /**
8379
+ * Gates the declaration callback to the connected window.
8380
+ *
8381
+ * Stimulus delivers a Value callback ahead of `connect()` and again for every
8382
+ * runtime change; without the gate, merely connecting would emit an evaluation
8383
+ * — and its `change` — before `connect()` runs its own.
8384
+ */
8385
+ #connected = false;
7983
8386
  connect() {
7984
- this.apply();
8387
+ this.#evaluate();
7985
8388
  this.element.addEventListener("change", this.#onChange);
8389
+ this.#connected = true;
7986
8390
  }
7987
8391
  disconnect() {
8392
+ this.#connected = false;
7988
8393
  this.element.removeEventListener("change", this.#onChange);
7989
8394
  }
8395
+ /**
8396
+ * Re-evaluates when the match declaration changes at runtime.
8397
+ *
8398
+ * The declaration decides which items are shown, so an element kept across a
8399
+ * morph — where `connect()` does not run again — still has to follow it instead
8400
+ * of waiting for the next control interaction. The evaluation is the ordinary
8401
+ * one, `change` included: a declaration swap is an evaluation like any other.
8402
+ */
8403
+ matchValueChanged() {
8404
+ if (!this.#connected) return;
8405
+ this.#evaluate();
8406
+ }
7990
8407
  /** Re-derives every item's visibility from the active tokens and syncs groups/empty. */
7991
8408
  apply() {
8409
+ this.#evaluate();
8410
+ }
8411
+ /**
8412
+ * The evaluation itself: every item's visibility from the active tokens, then
8413
+ * the groups and the empty element, then the `change` event.
8414
+ *
8415
+ * @stimeoRenderRoot
8416
+ */
8417
+ #evaluate() {
7992
8418
  const active = this.#activeTokens();
7993
8419
  let visibleCount = 0;
7994
8420
  for (const item of this.itemTargets) {
@@ -8339,13 +8765,39 @@ var FocusController = class extends Controller {
8339
8765
  initialFocus: () => this.hasInitialTarget ? this.initialTarget : null,
8340
8766
  onEscape: () => this.deactivate()
8341
8767
  });
8768
+ /**
8769
+ * Whether this scope is currently trapping.
8770
+ *
8771
+ * Held here rather than read back from the trap: the trap releases itself
8772
+ * before Turbo caches the page, so its own flag stops answering for the state
8773
+ * this controller publishes on the element and in its events.
8774
+ */
8775
+ #active = false;
8776
+ /**
8777
+ * Returns the element to its untrapped form before Turbo copies the page. The
8778
+ * trap performs its own release on the same event, so what is left is the hook
8779
+ * this controller owns — carried into the snapshot it would describe a scope
8780
+ * that is no longer trapping.
8781
+ *
8782
+ * Silent, like `disconnect()`: the page is about to be frozen, and a release
8783
+ * nobody asked for is not a close to report.
8784
+ */
8785
+ #beforeCache = new BeforeCacheReset(() => {
8786
+ this.#active = false;
8787
+ this.element.removeAttribute("data-focus-trapped");
8788
+ });
8342
8789
  /** Stimulus drives activation from the `trap` value (also fires on connect). */
8343
8790
  trapValueChanged() {
8344
8791
  if (this.trapValue) this.#activate();
8345
8792
  else this.#deactivate();
8346
8793
  }
8794
+ connect() {
8795
+ this.#beforeCache.activate();
8796
+ }
8347
8797
  disconnect() {
8348
8798
  this.#trap.deactivate({ restoreFocus: false });
8799
+ this.#beforeCache.deactivate();
8800
+ this.#active = false;
8349
8801
  this.element.removeAttribute("data-focus-trapped");
8350
8802
  }
8351
8803
  /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */
@@ -8359,13 +8811,15 @@ var FocusController = class extends Controller {
8359
8811
  this.#deactivate();
8360
8812
  }
8361
8813
  #activate() {
8362
- if (this.#trap.active) return;
8814
+ if (this.#active) return;
8815
+ this.#active = true;
8363
8816
  this.#trap.activate();
8364
8817
  this.element.setAttribute("data-focus-trapped", "true");
8365
8818
  this.dispatch("activate", { detail: {} });
8366
8819
  }
8367
8820
  #deactivate() {
8368
- if (!this.#trap.active) return;
8821
+ if (!this.#active) return;
8822
+ this.#active = false;
8369
8823
  this.#trap.deactivate({ restoreFocus: this.restoreValue });
8370
8824
  this.element.removeAttribute("data-focus-trapped");
8371
8825
  this.dispatch("deactivate", { detail: {} });
@@ -8383,7 +8837,7 @@ var FormFieldController = class _FormFieldController extends Controller {
8383
8837
  static #INVALID_ATTR = "data-stimeo--form-field-invalid";
8384
8838
  /** Collapses one target/morph batch into one silent ARIA reconciliation. */
8385
8839
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
8386
- /** ARIA ownership is scoped to the current singular control target. */
8840
+ /** Returns borrowed control ARIA before Turbo snapshots the page. */
8387
8841
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
8388
8842
  #ariaDescribedBy = new AttributeLease("aria-describedby");
8389
8843
  #ariaErrorMessage = new AttributeLease("aria-errormessage");
@@ -10500,14 +10954,37 @@ var LocalTimeController = class extends Controller {
10500
10954
  }
10501
10955
  };
10502
10956
  var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
10957
+ var DEFAULT_MIN_COLUMN_WIDTH = 240;
10958
+ var DEFAULT_GAP = 16;
10959
+ function usableNumber(value, fallback) {
10960
+ return Number.isFinite(value) ? value : fallback;
10961
+ }
10503
10962
  var MasonryController = class extends Controller {
10504
10963
  static targets = ["item"];
10505
10964
  static values = {
10506
- minColumnWidth: { type: Number, default: 240 },
10507
- gap: { type: Number, default: 16 }
10965
+ minColumnWidth: { type: Number, default: DEFAULT_MIN_COLUMN_WIDTH },
10966
+ gap: { type: Number, default: DEFAULT_GAP }
10508
10967
  };
10509
10968
  static events = ["layout"];
10510
- #layout = new LayoutObserver(() => this.#relayout());
10969
+ /**
10970
+ * The declared numbers after validation, so the layout path never sees a value
10971
+ * it cannot compute with. Both are resolved once per declaration change rather
10972
+ * than on every pass.
10973
+ */
10974
+ #minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH;
10975
+ #gap = DEFAULT_GAP;
10976
+ /**
10977
+ * Collapses every re-layout trigger of one DOM mutation into a single pass, and
10978
+ * refuses to run before `connect()` or after `disconnect()`.
10979
+ *
10980
+ * The triggers arrive in bursts — a resize stream, a morph that syncs several
10981
+ * attributes, a batch of rows — and each pass measures every item, so folding
10982
+ * them keeps the work proportional to the batch rather than to the events in it.
10983
+ */
10984
+ #reconcile = new MicrotaskCoalescer(() => this.#relayout());
10985
+ /** Items that left the target set and still carry the column hook. */
10986
+ #released = /* @__PURE__ */ new Set();
10987
+ #layout = new LayoutObserver(() => this.#reconcile.schedule());
10511
10988
  #mutationObserver = null;
10512
10989
  /** Last published column count, so `layout` fires only on real changes. */
10513
10990
  #lastColumns = 0;
@@ -10517,20 +10994,49 @@ var MasonryController = class extends Controller {
10517
10994
  * first pass ran before they settled; `load` does not bubble, so this is bound in
10518
10995
  * the capture phase to catch every descendant.
10519
10996
  */
10520
- #onLoad = () => this.#relayout();
10997
+ #onLoad = () => this.#reconcile.schedule();
10998
+ /** Resolves the declared column width once, falling back when it is unreadable. */
10999
+ minColumnWidthValueChanged() {
11000
+ this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);
11001
+ this.#reconcile.schedule();
11002
+ }
11003
+ /** Resolves the declared gap once, falling back when it is unreadable. */
11004
+ gapValueChanged() {
11005
+ this.#gap = usableNumber(this.gapValue, DEFAULT_GAP);
11006
+ this.#reconcile.schedule();
11007
+ }
11008
+ /** Packs an element that became an item without moving in the DOM. */
11009
+ itemTargetConnected() {
11010
+ this.#reconcile.schedule();
11011
+ }
11012
+ /**
11013
+ * Queues the column hook of an element that stopped being an item for removal.
11014
+ *
11015
+ * The removal is queued rather than immediate because teardown reports every
11016
+ * target as disconnected: doing it here would strip the whole grid just before
11017
+ * a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue
11018
+ * with the pass, so only a genuine target change reaches it.
11019
+ */
11020
+ itemTargetDisconnected(item) {
11021
+ this.#released.add(item);
11022
+ this.#reconcile.schedule();
11023
+ }
10521
11024
  /** Observes size/content changes and performs the first layout pass. */
10522
11025
  connect() {
10523
11026
  this.#layout.observe(this.element);
10524
11027
  this.#layout.observeViewport();
10525
11028
  if (typeof MutationObserver !== "undefined") {
10526
- this.#mutationObserver = new MutationObserver(() => this.#relayout());
11029
+ this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());
10527
11030
  this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
10528
11031
  }
10529
11032
  this.element.addEventListener("load", this.#onLoad, true);
10530
11033
  this.#relayout();
11034
+ this.#reconcile.activate();
10531
11035
  }
10532
11036
  /** Releases both observers and the load listener so nothing fires after detach. */
10533
11037
  disconnect() {
11038
+ this.#reconcile.cancel();
11039
+ this.#released.clear();
10534
11040
  this.#layout.disconnect();
10535
11041
  this.#mutationObserver?.disconnect();
10536
11042
  this.#mutationObserver = null;
@@ -10539,26 +11045,53 @@ var MasonryController = class extends Controller {
10539
11045
  }
10540
11046
  /**
10541
11047
  * 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.
11048
+ * Runs automatically on connect, on resize, on item add/remove, when a declared
11049
+ * number changes, and when a descendant resource loads (private — there is no
11050
+ * public action; the observers, the target callbacks and the capture-phase
11051
+ * `load` listener drive it). Items are walked in DOM order; each lands in the
11052
+ * column with the least accumulated height, which keeps the packing balanced
11053
+ * without reordering the DOM.
11054
+ *
11055
+ * Every box is measured before anything is written. Interleaving the two would
11056
+ * make a consumer's `data-column` rule invalidate style once per item, and the
11057
+ * next measurement then has to settle layout again — once per item instead of
11058
+ * once per pass. The assignment is independent of the measurement because the
11059
+ * columns are uniform in width, so the order of the two passes does not change
11060
+ * the result.
11061
+ *
11062
+ * @stimeoRenderRoot
10547
11063
  */
10548
11064
  #relayout() {
10549
11065
  const items = this.itemTargets;
10550
11066
  const columns = this.#columnCount();
11067
+ const boxes = items.map((item) => item.getBoundingClientRect().height);
11068
+ let changed = false;
11069
+ if (this.#released.size > 0) {
11070
+ const owned = new Set(items);
11071
+ for (const released of this.#released) {
11072
+ if (owned.has(released)) continue;
11073
+ if (released.hasAttribute("data-column")) {
11074
+ released.removeAttribute("data-column");
11075
+ changed = true;
11076
+ }
11077
+ }
11078
+ this.#released.clear();
11079
+ }
10551
11080
  const heights = new Array(columns).fill(0);
10552
- for (const item of items) {
11081
+ items.forEach((item, index) => {
10553
11082
  let shortest = 0;
10554
11083
  for (let col = 1; col < columns; col++) {
10555
11084
  if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;
10556
11085
  }
10557
- item.setAttribute("data-column", String(shortest));
10558
- heights[shortest] = (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;
10559
- }
11086
+ const assigned = String(shortest);
11087
+ if (item.getAttribute("data-column") !== assigned) {
11088
+ item.setAttribute("data-column", assigned);
11089
+ changed = true;
11090
+ }
11091
+ heights[shortest] = (heights[shortest] ?? 0) + (boxes[index] ?? 0) + this.#gap;
11092
+ });
10560
11093
  this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));
10561
- if (columns !== this.#lastColumns) {
11094
+ if (columns !== this.#lastColumns || changed) {
10562
11095
  this.#lastColumns = columns;
10563
11096
  this.dispatch("layout", { detail: { columns } });
10564
11097
  }
@@ -10571,9 +11104,9 @@ var MasonryController = class extends Controller {
10571
11104
  */
10572
11105
  #columnCount() {
10573
11106
  const width = this.element.getBoundingClientRect().width;
10574
- const denominator = this.minColumnWidthValue + this.gapValue;
11107
+ const denominator = this.#minColumnWidth + this.#gap;
10575
11108
  if (width <= 0 || denominator <= 0) return 1;
10576
- return Math.max(1, Math.floor((width + this.gapValue) / denominator));
11109
+ return Math.max(1, Math.floor((width + this.#gap) / denominator));
10577
11110
  }
10578
11111
  };
10579
11112
  var MenuController = class extends Controller {
@@ -11191,8 +11724,8 @@ var MenubarController = class extends Controller {
11191
11724
  * being hidden itself, a menu can stay visible after its owning top was removed,
11192
11725
  * an open pair can appear from a morph with no layer registered for it, and the
11193
11726
  * Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
11194
- * all. Nothing is remembered between calls (except which side of the Escape stack
11195
- * this layer is on, which the stack itself does not expose), so the outcome is the
11727
+ * all. The only state carried between calls is the tracked focus record and which side
11728
+ * of the Escape stack this layer is on (which the stack itself does not expose), so the outcome is the
11196
11729
  * same whichever mutation arrived and calling it more often than needed is free.
11197
11730
  */
11198
11731
  #reconcile() {
@@ -12306,7 +12839,7 @@ var MultiSelectController = class extends Controller {
12306
12839
  get #values() {
12307
12840
  return this.#selectedOptions.map((option) => this.#optionValue(option));
12308
12841
  }
12309
- /** Normalized cardinality cap: zero is unlimited and positive fractions round down. */
12842
+ /** Normalized cardinality cap: zero and below are unlimited; a positive value floors, never below 1. */
12310
12843
  get #selectionLimit() {
12311
12844
  if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
12312
12845
  return Math.max(1, Math.floor(this.maxValue));
@@ -13611,6 +14144,9 @@ function compilePattern(source) {
13611
14144
  function hasModifier2(event) {
13612
14145
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
13613
14146
  }
14147
+ function statesDiffer(left, right) {
14148
+ return left.value !== right.value || left.state !== right.state;
14149
+ }
13614
14150
  var OtpController = class extends Controller {
13615
14151
  static targets = ["field", "value", "error"];
13616
14152
  static values = {
@@ -13622,8 +14158,8 @@ var OtpController = class extends Controller {
13622
14158
  #pattern = new RegExp(`^${DEFAULT_PATTERN}$`);
13623
14159
  /** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
13624
14160
  #patternSource = DEFAULT_PATTERN;
13625
- /** Combined value carried by the last dispatch; keeps a no-op sync silent. */
13626
- #lastValue = null;
14161
+ /** Public state carried by the last dispatch; keeps a no-op sync silent. */
14162
+ #published = null;
13627
14163
  /** Field whose confirming `input` after `compositionend` is already handled. */
13628
14164
  #confirmedField = null;
13629
14165
  /** True between connect and disconnect, so pre-connect Value changes stay silent. */
@@ -13659,7 +14195,7 @@ var OtpController = class extends Controller {
13659
14195
  this.#beforeCache.activate();
13660
14196
  this.#reconcile.activate();
13661
14197
  this.#adopt();
13662
- this.#lastValue = this.#sync();
14198
+ this.#sync();
13663
14199
  }
13664
14200
  disconnect() {
13665
14201
  this.#connected = false;
@@ -13851,19 +14387,23 @@ var OtpController = class extends Controller {
13851
14387
  this.#markFilled(field, field.value);
13852
14388
  }
13853
14389
  /**
13854
- * Absorbs a batch of field additions or removals as one value transition.
14390
+ * Absorbs a batch of field additions or removals as one state transition.
13855
14391
  *
13856
- * The page, not the user, moved the value here, so it is reported as
14392
+ * The page, not the user, moved the state here, so it is reported as
13857
14393
  * `reconcile`: automation listening for `change` must not read a re-render as
13858
14394
  * an edit, and a passcode that happens to end up full must not fire the
13859
14395
  * `complete` that submits it.
14396
+ *
14397
+ * Completeness moves on its own when the field count changes: dropping a
14398
+ * trailing empty field completes a passcode whose combined value never moved,
14399
+ * and adding one un-completes it. Comparing the whole derived state, not the
14400
+ * string it contains, is what makes those transitions reportable.
13860
14401
  */
13861
14402
  #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 } });
14403
+ const previous = this.#published;
14404
+ const current = this.#sync();
14405
+ if (previous && !statesDiffer(previous, current)) return;
14406
+ this.dispatch("reconcile", { detail: { value: current.value } });
13867
14407
  }
13868
14408
  /**
13869
14409
  * Validates the text an entry point received and distributes what it accepts.
@@ -13958,23 +14498,29 @@ var OtpController = class extends Controller {
13958
14498
  const fields = this.fieldTargets;
13959
14499
  return fields.length > 0 && fields.every((field) => field.value.length > 0);
13960
14500
  }
13961
- /** Mirrors the combined value into the form and the root's readable state. */
14501
+ /**
14502
+ * Mirrors the combined value into the form and the root's readable state, and
14503
+ * records what was published so the next pass can compare against it.
14504
+ */
13962
14505
  #sync() {
13963
14506
  const combined = this.#combinedValue();
13964
14507
  if (this.hasValueTarget) {
13965
14508
  this.valueTarget.value = combined;
13966
14509
  }
13967
- this.#state.write(this.element, this.#stateName(combined));
13968
- return combined;
14510
+ const state = this.#stateName(combined);
14511
+ this.#state.write(this.element, state);
14512
+ const published = { value: combined, state };
14513
+ this.#published = published;
14514
+ return published;
13969
14515
  }
13970
14516
  #stateName(combined) {
13971
14517
  if (combined.length === 0) return "empty";
13972
14518
  return this.#isComplete() ? "complete" : "partial";
13973
14519
  }
13974
14520
  #syncAndDispatch() {
13975
- const combined = this.#sync();
13976
- if (combined === this.#lastValue) return;
13977
- this.#lastValue = combined;
14521
+ const previous = this.#published;
14522
+ const { value: combined } = this.#sync();
14523
+ if (previous?.value === combined) return;
13978
14524
  this.dispatch("change", { detail: { value: combined } });
13979
14525
  if (this.#isComplete()) {
13980
14526
  this.dispatch("complete", { detail: { value: combined } });
@@ -14448,8 +14994,8 @@ var OverflowMenuController = class extends Controller {
14448
14994
  *
14449
14995
  * With no known child at all (a fresh instance connecting to markup that already
14450
14996
  * holds banked items), a fully-banked snapshot's inert boundary preserves whether an
14451
- * unindexed run was prepended or appended before connect. Older or server-rendered
14452
- * markup has no boundary; for that compatibility path the saved index is used as the
14997
+ * unindexed run was prepended or appended before connect. Markup that holds banked items but no boundary —
14998
+ * server-rendered or hand-authored — uses the saved index as the
14453
14999
  * offset, the inverse of the move that banked it.
14454
15000
  */
14455
15001
  #merge(bar, banked, boundaryAt) {
@@ -14861,6 +15407,7 @@ var PaginationController = class _PaginationController extends Controller {
14861
15407
  return button.hasAttribute(_PaginationController.#BOUNDARY_ATTR);
14862
15408
  }
14863
15409
  };
15410
+ var MAX_DELAY = 2 ** 31 - 1;
14864
15411
  var PasswordRevealController = class extends Controller {
14865
15412
  static targets = ["input", "toggle"];
14866
15413
  static values = {
@@ -14870,11 +15417,54 @@ var PasswordRevealController = class extends Controller {
14870
15417
  static events = ["toggle"];
14871
15418
  /** Auto re-mask timer; torn down on disconnect. */
14872
15419
  #timers = new SafeTimeout();
15420
+ /**
15421
+ * Whether this controller is between `connect()` and `disconnect()`.
15422
+ *
15423
+ * Target callbacks outlive the controller: Stimulus stops the target observer
15424
+ * after `disconnect()`, so a field leaving after teardown still reaches
15425
+ * {@link PasswordRevealController.inputTargetDisconnected}. Arming from there
15426
+ * would put a timer back that nothing will clear. The element staying in the
15427
+ * document does not answer this — unloading the controller leaves it there.
15428
+ */
15429
+ #connected = false;
15430
+ /** Masks the field before Turbo copies the page into its snapshot. */
15431
+ #beforeCache = new BeforeCacheReset(() => this.#rewindToMasked());
14873
15432
  connect() {
14874
- this.#reflect(this.#isVisible);
15433
+ this.#connected = true;
15434
+ const visible = this.#isVisible;
15435
+ this.#reflect(visible);
15436
+ this.#beforeCache.activate();
15437
+ this.#arm(visible);
14875
15438
  }
14876
15439
  disconnect() {
15440
+ this.#connected = false;
14877
15441
  this.#timers.clearAll();
15442
+ this.#beforeCache.deactivate();
15443
+ }
15444
+ /** Re-derives the hooks and the re-mask for a field swapped in after connect. */
15445
+ inputTargetConnected() {
15446
+ const visible = this.#connected && this.#isVisible;
15447
+ this.#reflect(visible);
15448
+ this.#arm(visible);
15449
+ }
15450
+ /**
15451
+ * Re-derives from whatever field is left rather than assuming none is. A swap
15452
+ * delivers this callback next to the arrival in either order, so a revealed
15453
+ * replacement that answered "masked" here would be described as hidden while
15454
+ * showing the password, and would carry no re-mask.
15455
+ *
15456
+ * Teardown is the one case with nothing to derive: target callbacks run after
15457
+ * `disconnect()`, so the field is still revealed and re-arming from it would
15458
+ * outlive the controller. A detached root is the signal to stand down.
15459
+ */
15460
+ inputTargetDisconnected() {
15461
+ const visible = this.#connected && this.element.isConnected && this.#isVisible;
15462
+ this.#reflect(visible);
15463
+ this.#arm(visible);
15464
+ }
15465
+ /** Re-derives the pressed state for a button swapped in after connect. */
15466
+ toggleTargetConnected() {
15467
+ this.#reflect(this.#isVisible);
14878
15468
  }
14879
15469
  /** Toggles the input between masked and revealed. Bound via `data-action`. */
14880
15470
  toggle() {
@@ -14903,11 +15493,37 @@ var PasswordRevealController = class extends Controller {
14903
15493
  }
14904
15494
  this.#reflect(visible);
14905
15495
  this.dispatch("toggle", { detail: { visible } });
15496
+ this.#arm(visible);
15497
+ }
15498
+ /** Schedules the auto re-mask for a revealed field, replacing any pending one. */
15499
+ #arm(visible) {
14906
15500
  this.#timers.clearAll();
14907
- if (visible && this.autoHideValue > 0) {
14908
- this.#timers.set(() => this.#setVisible(false), this.autoHideValue);
15501
+ const delay = this.#autoHideDelay;
15502
+ if (visible && delay > 0) {
15503
+ this.#timers.set(() => this.#setVisible(false), delay);
14909
15504
  }
14910
15505
  }
15506
+ /**
15507
+ * The auto re-mask delay, held to what `setTimeout` can carry. Past that limit
15508
+ * a delay folds to zero, turning "keep it showing" into "hide it at once" —
15509
+ * the opposite of what the declaration asked for. A value that is no delay at
15510
+ * all stays out of the positive range {@link PasswordRevealController.#arm}
15511
+ * requires, so it schedules nothing.
15512
+ */
15513
+ get #autoHideDelay() {
15514
+ return Math.min(this.autoHideValue, MAX_DELAY);
15515
+ }
15516
+ /**
15517
+ * Returns the field to masked before Turbo copies the page. Silent and
15518
+ * focus-free: the page is about to be frozen, so there is no one to tell and
15519
+ * nowhere for focus to go.
15520
+ */
15521
+ #rewindToMasked() {
15522
+ this.#timers.clearAll();
15523
+ if (!this.hasInputTarget) return;
15524
+ this.inputTarget.type = "password";
15525
+ this.#reflect(false);
15526
+ }
14911
15527
  /** Reflects the visible state onto `aria-pressed` and `data-state`. */
14912
15528
  #reflect(visible) {
14913
15529
  if (this.hasToggleTarget) {
@@ -14921,6 +15537,7 @@ var DEFAULT_LEVELS = ["weak", "fair", "good", "strong"];
14921
15537
  var LENGTH_MILESTONES = [8, 12, 16];
14922
15538
  var MAX_POINTS = LENGTH_MILESTONES.length + (CLASS_PATTERNS.length - 1);
14923
15539
  var STRENGTH_BANDS = ["weak", "fair", "good", "strong"];
15540
+ var MIN_LEVELS = 2;
14924
15541
  var PasswordStrengthController = class _PasswordStrengthController extends Controller {
14925
15542
  static targets = ["input", "meter", "label"];
14926
15543
  static values = {
@@ -14928,50 +15545,143 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
14928
15545
  // A JSON list read through `parseStringList` rather than Stimulus's `Array`
14929
15546
  // type: that reader throws out of the value observer before any callback
14930
15547
  // runs, so one malformed attribute would stop the meter connecting.
14931
- levels: { type: String, default: "" }
15548
+ levels: { type: String, default: "" },
15549
+ announceText: { type: String, default: "" }
14932
15550
  };
14933
- static actions = ["evaluate"];
14934
- static events = ["change"];
14935
- /** Delay (ms) before the polite live-region label is written, to throttle SR flooding. */
15551
+ static actions = ["evaluate", "setScore"];
15552
+ static events = ["change", "reconcile"];
15553
+ /** Delay (ms) before one settled level is sent to the shared announcer. */
14936
15554
  static #announceDelay = 200;
14937
15555
  #timers = new SafeTimeout();
15556
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
15557
+ #repaint = new MicrotaskCoalescer(() => this.#reconcile());
15558
+ #levels = [...DEFAULT_LEVELS];
14938
15559
  #announceId = null;
15560
+ #announcedLevel = null;
15561
+ #externalScore = null;
15562
+ #lastDetail = null;
15563
+ /** Reflects the current DOM state and opens the reconciliation window. */
14939
15564
  connect() {
14940
- this.#update({ announce: false });
15565
+ this.#repaint.activate();
15566
+ this.#beforeCache.activate();
15567
+ this.#lastDetail = this.#detail(this.#render());
14941
15568
  }
15569
+ /** Releases the reconciliation window, the cache subscription and the debounce. */
14942
15570
  disconnect() {
14943
- this.#timers.clearAll();
14944
- this.#announceId = null;
15571
+ this.#repaint.cancel();
15572
+ this.#beforeCache.deactivate();
15573
+ this.#cancelAnnouncement();
15574
+ this.#announcedLevel = null;
15575
+ this.#externalScore = null;
15576
+ this.#lastDetail = null;
14945
15577
  }
14946
15578
  /** Re-evaluates strength from the input. Bound via `data-action` (`input`). */
14947
15579
  evaluate() {
14948
- this.#update();
15580
+ this.#externalScore = null;
15581
+ this.#commit();
15582
+ }
15583
+ /**
15584
+ * Adopts a score computed outside the built-in heuristic, clamped into the
15585
+ * declared scale. An unreadable value leaves the current score standing, and
15586
+ * the next `evaluate` hands scoring back to the heuristic.
15587
+ */
15588
+ setScore(event) {
15589
+ const next = toFiniteNumber(event.params?.score ?? event.detail?.score);
15590
+ if (next === null) return;
15591
+ this.#externalScore = next;
15592
+ this.#commit();
15593
+ }
15594
+ /** Re-reads the scale when application code or a Turbo morph changes `levels`. */
15595
+ levelsValueChanged() {
15596
+ this.#levels = this.#readLevels();
15597
+ this.#repaint.schedule();
15598
+ }
15599
+ /** Repaints when application code or a Turbo morph changes `minScore`. */
15600
+ minScoreValueChanged() {
15601
+ this.#repaint.schedule();
15602
+ }
15603
+ /** Repaints when application code or a Turbo morph changes `announceText`. */
15604
+ announceTextValueChanged() {
15605
+ this.#repaint.schedule();
15606
+ }
15607
+ /** Scores the field added or replaced at runtime. */
15608
+ inputTargetConnected() {
15609
+ this.#repaint.schedule();
15610
+ }
15611
+ /** Falls back to the pristine state after the scored field is removed. */
15612
+ inputTargetDisconnected() {
15613
+ this.#repaint.schedule();
15614
+ }
15615
+ /** Syncs a meter added or replaced at runtime, which carries no value yet. */
15616
+ meterTargetConnected() {
15617
+ this.#repaint.schedule();
15618
+ }
15619
+ /** Repaints the remaining output after a meter is removed. */
15620
+ meterTargetDisconnected() {
15621
+ this.#repaint.schedule();
15622
+ }
15623
+ /** Fills a readout added or replaced at runtime. */
15624
+ labelTargetConnected() {
15625
+ this.#repaint.schedule();
15626
+ }
15627
+ /** Repaints the remaining output after a readout is removed. */
15628
+ labelTargetDisconnected() {
15629
+ this.#repaint.schedule();
15630
+ }
15631
+ /** Reflects one confirmed scoring pass and offers its level to the reader. */
15632
+ #commit() {
15633
+ const reading = this.#render();
15634
+ this.#lastDetail = this.#detail(reading);
15635
+ this.dispatch("change", { detail: this.#lastDetail });
15636
+ this.#scheduleAnnouncement(reading);
15637
+ }
15638
+ /** Repaints one mutation batch and reports a changed controller-derived state. */
15639
+ #reconcile() {
15640
+ const previous = this.#lastDetail;
15641
+ const owed = this.#announceId !== null;
15642
+ this.#cancelAnnouncement();
15643
+ const reading = this.#render();
15644
+ const detail = this.#detail(reading);
15645
+ this.#lastDetail = detail;
15646
+ if (reading.score === 0) this.#announcedLevel = null;
15647
+ if (owed) this.#scheduleAnnouncement(reading);
15648
+ if (previous && this.#detailsDiffer(previous, detail)) {
15649
+ this.dispatch("reconcile", { detail });
15650
+ }
14949
15651
  }
14950
15652
  /**
14951
- * Recomputes the strength. The meter ARIA, data hooks, the custom property and
14952
- * the `change` event apply immediately; the live-region label text is debounced
14953
- * unless `announce` is `false` (the initial render).
15653
+ * Synchronizes the meter ARIA, the root state hooks, the fill custom property
15654
+ * and the visible level readout.
15655
+ *
15656
+ * @stimeoRenderRoot
14954
15657
  */
14955
- #update(options = {}) {
14956
- const password = this.hasInputTarget ? this.inputTarget.value : "";
14957
- const labels = parseStringList(this.levelsValue, DEFAULT_LEVELS);
14958
- const max = labels.length;
14959
- const score = this.#score(password, max);
14960
- const label = score > 0 ? labels[score - 1] ?? "" : "";
15658
+ #render() {
15659
+ const max = this.#levels.length;
15660
+ const score = this.#currentScore(max);
15661
+ const level = this.#levels[score - 1] ?? "";
15662
+ const meetsMin = score > 0 && score >= this.#minScore;
15663
+ const band = this.#band(score, max);
14961
15664
  this.#reflectMeter(score, max);
14962
- this.#reflectRoot(score, max);
14963
- if (options.announce === false) {
14964
- this.#writeLabel(label);
14965
- return;
15665
+ this.#reflectRoot(score, max, band, meetsMin);
15666
+ this.#writeLabel(level);
15667
+ return { score, level, max, meetsMin, band };
15668
+ }
15669
+ /**
15670
+ * The declared minimum, or the Value's default (`0`, an inert gate) when the
15671
+ * declaration cannot be read as a number. Every comparison against an unread
15672
+ * number answers false, which would fail even the strongest password instead
15673
+ * of leaving the gate off, so the unreadable declaration is confined to its
15674
+ * own Value the way an unreadable scale is.
15675
+ */
15676
+ get #minScore() {
15677
+ return Number.isFinite(this.minScoreValue) ? this.minScoreValue : 0;
15678
+ }
15679
+ /** The externally supplied score when one stands, else the heuristic's. */
15680
+ #currentScore(max) {
15681
+ if (this.#externalScore !== null) {
15682
+ return Math.min(max, Math.max(0, Math.round(this.#externalScore)));
14966
15683
  }
14967
- this.dispatch("change", {
14968
- detail: { score, level: label, max, meetsMin: score > 0 && score >= this.minScoreValue }
14969
- });
14970
- if (this.#announceId !== null) this.#timers.clear(this.#announceId);
14971
- this.#announceId = this.#timers.set(() => {
14972
- this.#writeLabel(label);
14973
- this.#announceId = null;
14974
- }, _PasswordStrengthController.#announceDelay);
15684
+ return this.#score(this.hasInputTarget ? this.inputTarget.value : "", max);
14975
15685
  }
14976
15686
  /** Syncs the meter target's ARIA value attributes (`0..levels.length`). */
14977
15687
  #reflectMeter(score, max) {
@@ -14985,25 +15695,27 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
14985
15695
  * empty), the `data-below-min` hook when the score is under `minScore`, and the
14986
15696
  * `0–1` fill the consumer's CSS turns into the bar width.
14987
15697
  */
14988
- #reflectRoot(score, max) {
14989
- const band = this.#band(score, max);
15698
+ #reflectRoot(score, max, band, meetsMin) {
14990
15699
  this.#toggle("data-strength", band, band.length > 0);
14991
- this.#toggle("data-below-min", "true", score > 0 && score < this.minScoreValue);
14992
- const ratio = max > 0 ? score / max : 0;
14993
- this.element.style.setProperty("--stimeo--password-strength", String(ratio));
15700
+ this.#toggle("data-below-min", "true", score > 0 && !meetsMin);
15701
+ this.element.style.setProperty("--stimeo--password-strength", String(score / max));
14994
15702
  }
14995
- #writeLabel(label) {
14996
- if (this.hasLabelTarget) this.labelTarget.textContent = label;
15703
+ /** Writes the visible readout only when its text actually changed. */
15704
+ #writeLabel(text) {
15705
+ if (!this.hasLabelTarget || this.labelTarget.textContent === text) return;
15706
+ this.labelTarget.textContent = text;
14997
15707
  }
14998
15708
  /**
14999
15709
  * Locale-independent styling band (one of {@link STRENGTH_BANDS}) for `score`
15000
- * out of `max`. Empty input → `""`. Quantizes the `score/max` ratio into the
15001
- * four fixed bands, so a non-default level count still maps onto a stable hook.
15710
+ * out of `max`. Empty input → `""`. Maps the position within the declared scale
15711
+ * onto the position within the fixed bands, so both ends stay anchored on any
15712
+ * level count: the weakest score is always `weak` and the strongest `strong`,
15713
+ * and only the middle collapses when the scales differ in size.
15002
15714
  */
15003
15715
  #band(score, max) {
15004
- if (score <= 0 || max <= 0) return "";
15005
- const index = Math.ceil(score / max * STRENGTH_BANDS.length) - 1;
15006
- return STRENGTH_BANDS[Math.min(STRENGTH_BANDS.length - 1, Math.max(0, index))] ?? "";
15716
+ if (score <= 0) return "";
15717
+ const index = Math.round((score - 1) / (max - 1) * (STRENGTH_BANDS.length - 1));
15718
+ return STRENGTH_BANDS[index] ?? STRENGTH_BANDS[0];
15007
15719
  }
15008
15720
  /** Sets `name` to `value` when `on`, else removes it (value/presence data hook). */
15009
15721
  #toggle(name, value, on) {
@@ -15013,6 +15725,11 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
15013
15725
  this.element.removeAttribute(name);
15014
15726
  }
15015
15727
  }
15728
+ /** The declared level labels, or the defaults when the scale cannot order. */
15729
+ #readLevels() {
15730
+ const declared = parseStringList(this.levelsValue, DEFAULT_LEVELS);
15731
+ return declared.length >= MIN_LEVELS ? declared : [...DEFAULT_LEVELS];
15732
+ }
15016
15733
  /**
15017
15734
  * Lightweight zero-dependency strength heuristic returning an integer in
15018
15735
  * `[0, max]` (`max` = number of levels). Empty input is `0` (no level); any
@@ -15022,7 +15739,7 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
15022
15739
  * alone cannot mask trivial repetition.
15023
15740
  */
15024
15741
  #score(password, max) {
15025
- if (password.length === 0 || max === 0) return 0;
15742
+ if (password.length === 0) return 0;
15026
15743
  let points = 0;
15027
15744
  for (const milestone of LENGTH_MILESTONES) {
15028
15745
  if (password.length >= milestone) points += 1;
@@ -15032,6 +15749,64 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
15032
15749
  const bucketed = Math.round(points / MAX_POINTS * max);
15033
15750
  return Math.min(max, Math.max(1, bucketed));
15034
15751
  }
15752
+ /** Debounces one i18n-neutral message for a level transition into the announcer. */
15753
+ #scheduleAnnouncement(reading) {
15754
+ this.#cancelAnnouncement();
15755
+ if (reading.score === 0) {
15756
+ this.#announcedLevel = null;
15757
+ return;
15758
+ }
15759
+ if (reading.level === this.#announcedLevel) return;
15760
+ const message = fillTemplate(this.announceTextValue, {
15761
+ level: reading.level,
15762
+ score: reading.score,
15763
+ max: reading.max,
15764
+ band: reading.band
15765
+ });
15766
+ if (message.trim().length === 0) return;
15767
+ this.#announceId = this.#timers.set(() => {
15768
+ this.#announcedLevel = reading.level;
15769
+ announce(message);
15770
+ this.#announceId = null;
15771
+ }, _PasswordStrengthController.#announceDelay);
15772
+ }
15773
+ /** Cancels the one outstanding message without touching visible output. */
15774
+ #cancelAnnouncement() {
15775
+ if (this.#announceId !== null) this.#timers.clear(this.#announceId);
15776
+ this.#announceId = null;
15777
+ }
15778
+ /**
15779
+ * Returns every output derived from the field value to its pristine form.
15780
+ *
15781
+ * The value itself is not carried in a Turbo snapshot, so a band, a fill or a
15782
+ * readout left behind would describe a password the restored page no longer
15783
+ * holds. The pass is silent: `connect()` derives the state again from whatever
15784
+ * the restored field contains.
15785
+ */
15786
+ #rewindForCache() {
15787
+ this.#cancelAnnouncement();
15788
+ this.#announcedLevel = null;
15789
+ this.#externalScore = null;
15790
+ this.#lastDetail = null;
15791
+ this.#reflectMeter(0, this.#levels.length);
15792
+ this.#toggle("data-strength", "", false);
15793
+ this.#toggle("data-below-min", "true", false);
15794
+ this.element.style.removeProperty("--stimeo--password-strength");
15795
+ this.#writeLabel("");
15796
+ }
15797
+ /** Selects the public event state from the richer internal reading. */
15798
+ #detail(reading) {
15799
+ return {
15800
+ score: reading.score,
15801
+ level: reading.level,
15802
+ max: reading.max,
15803
+ meetsMin: reading.meetsMin
15804
+ };
15805
+ }
15806
+ /** Compares exactly the state carried by `change` and `reconcile`. */
15807
+ #detailsDiffer(left, right) {
15808
+ return left.score !== right.score || left.level !== right.level || left.max !== right.max || left.meetsMin !== right.meetsMin;
15809
+ }
15035
15810
  };
15036
15811
 
15037
15812
  // src/utils/safe_storage.ts
@@ -16215,25 +16990,6 @@ var ProgressController = class extends Controller {
16215
16990
  this.element.removeAttribute(OWNED_VALUE_TEXT2);
16216
16991
  }
16217
16992
  };
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
16993
  var OBSERVED_ATTRIBUTES4 = [
16238
16994
  "aria-checked",
16239
16995
  "aria-disabled",
@@ -16718,80 +17474,25 @@ var RadioGroupController = class extends Controller {
16718
17474
  this.#markInternal(this.#internalCheckedValues, radio, value);
16719
17475
  radio.setAttribute("aria-checked", value);
16720
17476
  }
16721
- /** Writes a roving value before the radio is visible to the shared primitive. */
16722
- #writeTabindex(radio, value) {
16723
- const serialized = String(value);
16724
- this.#markInternal(this.#internalTabindexValues, radio, serialized);
16725
- radio.tabIndex = value;
16726
- }
16727
- /** Mirrors the selected value or the empty state to the optional hidden field. */
16728
- #reflectField(radio, { silent = false } = {}) {
16729
- if (!this.hasFieldTarget) return;
16730
- const value = radio ? this.#radioValue(radio) : "";
16731
- if (this.fieldTarget.value === value) return;
16732
- this.fieldTarget.value = value;
16733
- if (!silent) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
16734
- }
16735
- /** A radio's submitted value (`data-value`, defaulting to empty). */
16736
- #radioValue(radio) {
16737
- return radio.getAttribute("data-value") ?? "";
16738
- }
16739
- };
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;
17477
+ /** Writes a roving value before the radio is visible to the shared primitive. */
17478
+ #writeTabindex(radio, value) {
17479
+ const serialized = String(value);
17480
+ this.#markInternal(this.#internalTabindexValues, radio, serialized);
17481
+ radio.tabIndex = value;
16765
17482
  }
16766
- /** Whether `event` belongs to the initiating pointer of the live session. */
16767
- owns(event) {
16768
- return this.#active && event.pointerId === this.pointerId;
17483
+ /** Mirrors the selected value or the empty state to the optional hidden field. */
17484
+ #reflectField(radio, { silent = false } = {}) {
17485
+ if (!this.hasFieldTarget) return;
17486
+ const value = radio ? this.#radioValue(radio) : "";
17487
+ if (this.fieldTarget.value === value) return;
17488
+ this.fieldTarget.value = value;
17489
+ if (!silent) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
16769
17490
  }
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?.();
17491
+ /** A radio's submitted value (`data-value`, defaulting to empty). */
17492
+ #radioValue(radio) {
17493
+ return radio.getAttribute("data-value") ?? "";
16780
17494
  }
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
17495
  };
16793
-
16794
- // src/controllers/range_slider_controller.ts
16795
17496
  var START_PROPERTY = "--stimeo--range-slider-start";
16796
17497
  var END_PROPERTY = "--stimeo--range-slider-end";
16797
17498
  var DEFAULT_MIN = 0;
@@ -17641,6 +18342,26 @@ var RelativeTimeController = class extends Controller {
17641
18342
  return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
17642
18343
  }
17643
18344
  };
18345
+ var STATELESS_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "reset", "button", "image"]);
18346
+ function restoreField(element) {
18347
+ if (element instanceof HTMLTextAreaElement) {
18348
+ element.value = element.defaultValue;
18349
+ return;
18350
+ }
18351
+ if (element instanceof HTMLSelectElement) {
18352
+ for (const option of element.options) option.selected = option.hasAttribute("selected");
18353
+ return;
18354
+ }
18355
+ if (element instanceof HTMLInputElement) {
18356
+ if (element.type === "checkbox" || element.type === "radio") {
18357
+ element.checked = element.defaultChecked;
18358
+ } else if (element.type === "file") {
18359
+ element.value = "";
18360
+ } else if (!STATELESS_INPUT_TYPES.has(element.type)) {
18361
+ element.value = element.defaultValue;
18362
+ }
18363
+ }
18364
+ }
17644
18365
  var ResetBeforeCacheController = class extends Controller {
17645
18366
  static values = {
17646
18367
  scope: { type: String, default: "" },
@@ -17648,8 +18369,32 @@ var ResetBeforeCacheController = class extends Controller {
17648
18369
  };
17649
18370
  static actions = ["reset"];
17650
18371
  static events = ["reset", "request"];
18372
+ /** The `scope` declaration after validation; empty when it cannot be parsed. */
18373
+ #scopeSelector = "";
17651
18374
  /** Runs the reset just before Turbo caches the snapshot. */
17652
18375
  #onBeforeCache = () => this.reset();
18376
+ /**
18377
+ * Validates the scope declaration once, keeping only a selector the engine can
18378
+ * read.
18379
+ *
18380
+ * A selector reads back as an ordinary string, so a malformed one survives
18381
+ * until it is handed to the DOM — and this part runs from a single listener
18382
+ * whose whole job is to keep a cached page from freezing. Falling back to the
18383
+ * default keeps that job running with a visible, findable result instead of
18384
+ * silently taking the sweep down.
18385
+ */
18386
+ scopeValueChanged() {
18387
+ const selector = this.scopeValue;
18388
+ if (selector.length > 0) {
18389
+ try {
18390
+ this.element.matches(selector);
18391
+ this.#scopeSelector = selector;
18392
+ return;
18393
+ } catch {
18394
+ }
18395
+ }
18396
+ this.#scopeSelector = "";
18397
+ }
17653
18398
  connect() {
17654
18399
  document.addEventListener("turbo:before-cache", this.#onBeforeCache);
17655
18400
  }
@@ -17660,6 +18405,10 @@ var ResetBeforeCacheController = class extends Controller {
17660
18405
  * Resets transient UI within scope to its initial state. Asks controllers to
17661
18406
  * close (via `request`) first, then applies the declarative `data-reset-*` cleanup,
17662
18407
  * and finally emits `reset`. Safe to call any number of times (idempotent).
18408
+ *
18409
+ * `dispatchReset` decides only whether the `request` ask goes out; the cleanup
18410
+ * and the closing `reset` run either way. Both the listener and this action
18411
+ * reach the same sweep, so `reset` is emitted for a manual call too.
17663
18412
  */
17664
18413
  reset() {
17665
18414
  const root = this.#scopeRoot();
@@ -17678,9 +18427,7 @@ var ResetBeforeCacheController = class extends Controller {
17678
18427
  if (element instanceof HTMLFormElement) element.reset();
17679
18428
  }
17680
18429
  for (const element of root.querySelectorAll("[data-reset-value]")) {
17681
- if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) {
17682
- element.value = "";
17683
- }
18430
+ restoreField(element);
17684
18431
  }
17685
18432
  for (const element of root.querySelectorAll("[data-reset-hidden]")) {
17686
18433
  element.hidden = true;
@@ -17692,30 +18439,38 @@ var ResetBeforeCacheController = class extends Controller {
17692
18439
  }
17693
18440
  /** The scan root: a `scope` descendant when set, else the controller element. */
17694
18441
  #scopeRoot() {
17695
- if (!this.scopeValue) return this.element;
17696
- return this.element.querySelector(this.scopeValue) ?? this.element;
18442
+ if (!this.#scopeSelector) return this.element;
18443
+ return this.element.querySelector(this.#scopeSelector) ?? this.element;
17697
18444
  }
17698
18445
  };
18446
+ var DEFAULT_MIN2 = 0;
18447
+ var DEFAULT_MAX2 = 100;
18448
+ var DEFAULT_VALUE = 50;
18449
+ var DEFAULT_STEP = 1;
17699
18450
  var ResizableController = class extends Controller {
17700
18451
  static targets = ["primary", "secondary", "separator"];
17701
18452
  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 }
18453
+ min: { type: Number, default: DEFAULT_MIN2 },
18454
+ max: { type: Number, default: DEFAULT_MAX2 },
18455
+ step: { type: Number, default: DEFAULT_STEP },
18456
+ value: { type: Number, default: DEFAULT_VALUE }
17706
18457
  };
17707
18458
  static actions = ["onKeydown", "onPointerDown", "toggle"];
17708
18459
  static events = ["change"];
17709
- /** The value held before the current collapse, restored when toggling back. */
17710
- #valueBeforeCollapse = 50;
18460
+ /** Where the divider sat before the current collapse; `null` when unknown. */
18461
+ #valueBeforeCollapse = null;
17711
18462
  /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
17712
18463
  #dragAbort = null;
17713
18464
  /** `tabindex` lent to a pane so `F6` can put focus on it; panes carry none. */
17714
18465
  #paneTabindex = new TabindexLoan();
17715
18466
  /** Releases the pane cycle listener bound in {@link connect}. */
17716
18467
  #cycleAbort = null;
18468
+ /** Folds the range and position inputs of one morph batch into a single paint. */
18469
+ #repaint = new MicrotaskCoalescer(() => this.#render());
17717
18470
  connect() {
17718
- this.#clampAndSync();
18471
+ this.element.removeAttribute("data-dragging");
18472
+ this.#repaint.activate();
18473
+ this.#render();
17719
18474
  this.#cycleAbort = new AbortController();
17720
18475
  this.element.addEventListener("keydown", this.#onCycleKeydown, {
17721
18476
  signal: this.#cycleAbort.signal
@@ -17727,17 +18482,19 @@ var ResizableController = class extends Controller {
17727
18482
  this.#dragAbort = null;
17728
18483
  this.#cycleAbort?.abort();
17729
18484
  this.#cycleAbort = null;
18485
+ this.#repaint.cancel();
17730
18486
  this.#paneTabindex.returnAll();
18487
+ this.element.removeAttribute("data-dragging");
17731
18488
  }
17732
18489
  /** Moves focus to the next pane on `F6`, wrapping; entering at the first one. */
17733
18490
  #onCycleKeydown = (event) => {
17734
18491
  if (event.key !== "F6") return;
18492
+ if (event.isComposing) return;
17735
18493
  if (event.defaultPrevented) return;
17736
18494
  if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
17737
18495
  const panes = [];
17738
18496
  if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
17739
18497
  if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
17740
- if (panes.length === 0) return;
17741
18498
  const target = event.target;
17742
18499
  const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
17743
18500
  const next = panes[(current + 1) % panes.length];
@@ -17746,12 +18503,21 @@ var ResizableController = class extends Controller {
17746
18503
  this.#paneTabindex.lend(next);
17747
18504
  next.focus();
17748
18505
  };
17749
- /**
17750
- * Stimulus lifecycle callback when the valueValue changes.
17751
- * Keeps CSS fractions and ARIA status completely aligned.
17752
- */
18506
+ /** Re-renders when application code (or a Turbo morph) changes `value` at runtime. */
17753
18507
  valueValueChanged() {
17754
- this.#clampAndSync();
18508
+ this.#repaint.schedule();
18509
+ }
18510
+ /** Re-renders when application code (or a Turbo morph) changes `min` at runtime. */
18511
+ minValueChanged() {
18512
+ this.#repaint.schedule();
18513
+ }
18514
+ /** Re-renders when application code (or a Turbo morph) changes `max` at runtime. */
18515
+ maxValueChanged() {
18516
+ this.#repaint.schedule();
18517
+ }
18518
+ /** Re-publishes range and position onto a separator swapped in after connect. */
18519
+ separatorTargetConnected() {
18520
+ this.#repaint.schedule();
17755
18521
  }
17756
18522
  /** Starts active pointer drag tracking and locks capture. */
17757
18523
  onPointerDown(event) {
@@ -17772,44 +18538,44 @@ var ResizableController = class extends Controller {
17772
18538
  onKeydown(event) {
17773
18539
  if (isReservedArrowChord(event)) return;
17774
18540
  if (!this.hasSeparatorTarget) return;
17775
- const orientation = this.separatorTarget.getAttribute("aria-orientation") || "vertical";
17776
- const isVertical = orientation === "vertical";
18541
+ const { min, max } = this.#range;
18542
+ const isVertical = this.#isVertical;
17777
18543
  let handled = true;
17778
- let nextValue = this.valueValue;
18544
+ let nextValue = this.#position;
17779
18545
  switch (event.key) {
17780
18546
  case "ArrowLeft":
17781
18547
  if (isVertical) {
17782
- nextValue -= this.stepValue;
18548
+ nextValue -= this.#step;
17783
18549
  } else {
17784
18550
  handled = false;
17785
18551
  }
17786
18552
  break;
17787
18553
  case "ArrowRight":
17788
18554
  if (isVertical) {
17789
- nextValue += this.stepValue;
18555
+ nextValue += this.#step;
17790
18556
  } else {
17791
18557
  handled = false;
17792
18558
  }
17793
18559
  break;
17794
18560
  case "ArrowUp":
17795
18561
  if (!isVertical) {
17796
- nextValue -= this.stepValue;
18562
+ nextValue -= this.#step;
17797
18563
  } else {
17798
18564
  handled = false;
17799
18565
  }
17800
18566
  break;
17801
18567
  case "ArrowDown":
17802
18568
  if (!isVertical) {
17803
- nextValue += this.stepValue;
18569
+ nextValue += this.#step;
17804
18570
  } else {
17805
18571
  handled = false;
17806
18572
  }
17807
18573
  break;
17808
18574
  case "Home":
17809
- nextValue = this.minValue;
18575
+ nextValue = min;
17810
18576
  break;
17811
18577
  case "End":
17812
- nextValue = this.maxValue;
18578
+ nextValue = max;
17813
18579
  break;
17814
18580
  case "Enter":
17815
18581
  event.preventDefault();
@@ -17821,65 +18587,86 @@ var ResizableController = class extends Controller {
17821
18587
  }
17822
18588
  if (handled) {
17823
18589
  event.preventDefault();
17824
- this.valueValue = Math.max(this.minValue, Math.min(nextValue, this.maxValue));
17825
- this.#clampAndSync();
18590
+ this.valueValue = Math.max(min, Math.min(nextValue, max));
18591
+ this.#render();
17826
18592
  this.#dispatchChange();
17827
18593
  }
17828
18594
  }
17829
- /** Double-click or Enter to collapse/restore pane to min/max levels. */
18595
+ /** Collapses the primary pane to its minimum, or returns it to the last position. */
17830
18596
  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;
18597
+ const { min, max } = this.#range;
18598
+ if (this.#position > min) {
18599
+ this.#valueBeforeCollapse = this.#position;
18600
+ this.valueValue = min;
17835
18601
  } else {
17836
- this.valueValue = this.#valueBeforeCollapse >= threshold ? this.#valueBeforeCollapse : this.maxValue;
18602
+ this.valueValue = this.#valueBeforeCollapse ?? max;
18603
+ this.#valueBeforeCollapse = null;
17837
18604
  }
17838
- this.#clampAndSync();
18605
+ this.#render();
17839
18606
  this.#dispatchChange();
17840
18607
  }
17841
18608
  #onPointerMove = (event) => {
17842
18609
  if (!this.hasSeparatorTarget) return;
17843
18610
  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();
18611
+ const raw = this.#isVertical ? (event.clientX - rect.left) / rect.width : (event.clientY - rect.top) / rect.height;
18612
+ const fraction = Number.isFinite(raw) ? Math.max(0, Math.min(raw, 1)) : 0;
18613
+ const { min, max } = this.#range;
18614
+ this.valueValue = Math.max(min, Math.min(Math.round(fraction * 100), max));
18615
+ this.#render();
17856
18616
  };
17857
18617
  #onPointerUp = (event) => {
17858
- if (!this.hasSeparatorTarget) return;
17859
- const separator = this.separatorTarget;
17860
- separator.releasePointerCapture(event.pointerId);
17861
18618
  this.element.removeAttribute("data-dragging");
17862
18619
  this.#dragAbort?.abort();
17863
18620
  this.#dragAbort = null;
18621
+ if (this.hasSeparatorTarget) {
18622
+ this.separatorTarget.releasePointerCapture(event.pointerId);
18623
+ }
17864
18624
  this.#dispatchChange();
17865
18625
  };
17866
- #clampAndSync() {
17867
- const clamped = Math.max(this.minValue, Math.min(this.valueValue, this.maxValue));
17868
- if (this.valueValue !== clamped) {
17869
- this.valueValue = clamped;
18626
+ /**
18627
+ * Publishes the position: the fraction consumer CSS multiplies a pane by, and
18628
+ * the range assistive tech reads off the separator. The clamped position is
18629
+ * written back to the Value so it, ARIA, CSS, and dispatched events agree.
18630
+ *
18631
+ * @stimeoRenderRoot
18632
+ */
18633
+ #render() {
18634
+ const { min, max } = this.#range;
18635
+ const position = this.#position;
18636
+ if (this.valueValue !== position) {
18637
+ this.valueValue = position;
17870
18638
  }
17871
- const fraction = clamped / 100;
17872
- this.element.style.setProperty("--stimeo--resizable-fraction", String(fraction));
18639
+ this.element.style.setProperty("--stimeo--resizable-fraction", String(position / 100));
17873
18640
  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));
18641
+ this.separatorTarget.setAttribute("aria-valuenow", String(position));
18642
+ this.separatorTarget.setAttribute("aria-valuemin", String(min));
18643
+ this.separatorTarget.setAttribute("aria-valuemax", String(max));
17877
18644
  }
17878
18645
  }
17879
18646
  #dispatchChange() {
17880
18647
  const fraction = this.valueValue / 100;
17881
18648
  this.dispatch("change", { detail: { value: this.valueValue, fraction } });
17882
18649
  }
18650
+ /** The declared range after validation; an unreadable bound uses its default. */
18651
+ get #range() {
18652
+ const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN2;
18653
+ const max = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX2;
18654
+ return { min, max: Math.max(min, max) };
18655
+ }
18656
+ /** The declared position, clamped into the validated range. */
18657
+ get #position() {
18658
+ const { min, max } = this.#range;
18659
+ const value = Number.isFinite(this.valueValue) ? this.valueValue : DEFAULT_VALUE;
18660
+ return Math.max(min, Math.min(value, max));
18661
+ }
18662
+ /** The keyboard increment; a non-positive or unreadable one uses the default. */
18663
+ get #step() {
18664
+ return Number.isFinite(this.stepValue) && this.stepValue > 0 ? this.stepValue : DEFAULT_STEP;
18665
+ }
18666
+ /** Whether the divider runs vertically; a separator that does not say is horizontal. */
18667
+ get #isVertical() {
18668
+ return this.hasSeparatorTarget && this.separatorTarget.getAttribute("aria-orientation") === "vertical";
18669
+ }
17883
18670
  };
17884
18671
  var RovingController = class extends Controller {
17885
18672
  static targets = ["item"];
@@ -18387,6 +19174,17 @@ var ScrollAreaController = class extends Controller {
18387
19174
  this.#clearHostState();
18388
19175
  }
18389
19176
  };
19177
+ function parseStored(raw) {
19178
+ if (raw === null) return null;
19179
+ try {
19180
+ return JSON.parse(raw);
19181
+ } catch {
19182
+ return null;
19183
+ }
19184
+ }
19185
+ function storedOffset(value) {
19186
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
19187
+ }
18390
19188
  var ScrollRestoreController = class extends Controller {
18391
19189
  static values = {
18392
19190
  key: { type: String, default: "" },
@@ -18399,6 +19197,26 @@ var ScrollRestoreController = class extends Controller {
18399
19197
  /** Last offset captured while the element was live; persisted as-is on teardown. */
18400
19198
  #lastTop = 0;
18401
19199
  #lastLeft = 0;
19200
+ /**
19201
+ * The offsets this controller's own restore produced, awaiting the `scroll`
19202
+ * the engine fires for them. A restore the layout cannot reach yet lands short,
19203
+ * so treating that echo as the reader's position would cut the saved offset
19204
+ * down to whatever the unfinished layout allowed.
19205
+ */
19206
+ #echoTop = null;
19207
+ #echoLeft = null;
19208
+ /** Offsets stored for an axis that is not tracked, carried through saves. */
19209
+ #carried = {};
19210
+ /**
19211
+ * Whether each axis holds an offset worth saving — one this namespace restored
19212
+ * or the reader moved. A save writes only the axes that are marked, so an
19213
+ * offset taken under a different key or axis is never re-published as if the
19214
+ * reader had left it there.
19215
+ */
19216
+ #capturedTop = false;
19217
+ #capturedLeft = false;
19218
+ /** Whether the controller is connected; Value callbacks outside that window do not resync. */
19219
+ #connected = false;
18402
19220
  #onScroll = () => {
18403
19221
  this.#capture();
18404
19222
  if (this.#rafId !== null) return;
@@ -18408,58 +19226,127 @@ var ScrollRestoreController = class extends Controller {
18408
19226
  });
18409
19227
  };
18410
19228
  connect() {
19229
+ this.#connected = true;
18411
19230
  this.#storageKey = this.#resolveKey();
18412
- if (!this.#storageKey) return;
18413
- this.#restore();
18414
19231
  this.element.addEventListener("scroll", this.#onScroll, { passive: true });
19232
+ if (this.#storageKey) this.#restore();
18415
19233
  }
18416
19234
  disconnect() {
18417
- if (!this.#storageKey) return;
19235
+ this.#connected = false;
18418
19236
  this.element.removeEventListener("scroll", this.#onScroll);
19237
+ this.#cancelFrame();
19238
+ this.#persist();
19239
+ }
19240
+ /** Re-derives the namespace when application code or a Turbo morph moves `key`. */
19241
+ keyValueChanged() {
19242
+ this.#resync();
19243
+ }
19244
+ /** Re-derives the tracked axes when application code or a Turbo morph moves `axis`. */
19245
+ axisValueChanged() {
19246
+ this.#resync();
19247
+ }
19248
+ /** Rebuilds the persistence state around the Values as they now read. */
19249
+ #resync() {
19250
+ if (!this.#connected) return;
19251
+ this.#cancelFrame();
19252
+ this.#storageKey = this.#resolveKey();
19253
+ this.#echoTop = null;
19254
+ this.#echoLeft = null;
19255
+ this.#carried = {};
19256
+ this.#capturedTop = false;
19257
+ this.#capturedLeft = false;
19258
+ if (this.#storageKey) this.#restore();
19259
+ }
19260
+ /** Drops the pending coalesced save, if one is queued. */
19261
+ #cancelFrame() {
18419
19262
  if (this.#rafId !== null) {
18420
19263
  cancelAnimationFrame(this.#rafId);
18421
19264
  this.#rafId = null;
18422
19265
  }
18423
- this.#persist();
18424
19266
  }
18425
- /** Records the live scroll offset for the configured axis. */
19267
+ /**
19268
+ * Records the live scroll offset for the configured axis.
19269
+ *
19270
+ * An axis holds its echo until that axis actually moves. A scroll event names
19271
+ * no axis, so consuming both on the first one to arrive would leave the other
19272
+ * unguarded: on `both`, moving one axis would take the clamped reading of the
19273
+ * untouched axis as the reader's own position and save it, cutting the stored
19274
+ * offset down to whatever the layout could reach at restore time.
19275
+ */
18426
19276
  #capture() {
18427
- if (this.#tracksVertical) this.#lastTop = this.element.scrollTop;
18428
- if (this.#tracksHorizontal) this.#lastLeft = this.element.scrollLeft;
19277
+ if (this.#tracksVertical) {
19278
+ const top = this.element.scrollTop;
19279
+ if (top !== this.#echoTop) {
19280
+ this.#echoTop = null;
19281
+ this.#lastTop = top;
19282
+ this.#capturedTop = true;
19283
+ }
19284
+ }
19285
+ if (this.#tracksHorizontal) {
19286
+ const left = this.element.scrollLeft;
19287
+ if (left !== this.#echoLeft) {
19288
+ this.#echoLeft = null;
19289
+ this.#lastLeft = left;
19290
+ this.#capturedLeft = true;
19291
+ }
19292
+ }
18429
19293
  }
18430
- /** Persists the last captured scroll offset for the configured axis. */
19294
+ /**
19295
+ * Writes the marked axes, carrying through any offset held for an untracked one.
19296
+ * Without a key, or with neither axis marked, it writes nothing.
19297
+ */
18431
19298
  #persist() {
18432
- const data = {};
18433
- if (this.#tracksVertical) data.top = this.#lastTop;
18434
- if (this.#tracksHorizontal) data.left = this.#lastLeft;
19299
+ if (!this.#storageKey || !(this.#capturedTop || this.#capturedLeft)) return;
19300
+ const data = { ...this.#carried };
19301
+ if (this.#tracksVertical && this.#capturedTop) data.top = this.#lastTop;
19302
+ if (this.#tracksHorizontal && this.#capturedLeft) data.left = this.#lastLeft;
18435
19303
  try {
18436
- sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
19304
+ window.sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
18437
19305
  } catch {
18438
19306
  }
18439
19307
  }
18440
19308
  /** Applies the persisted scroll offset, if any, without moving focus. */
18441
19309
  #restore() {
18442
- let raw = null;
18443
- try {
18444
- raw = sessionStorage.getItem(this.#storageKey);
18445
- } catch {
18446
- return;
18447
- }
18448
- if (raw === null) return;
18449
- let data;
19310
+ let raw;
18450
19311
  try {
18451
- data = JSON.parse(raw);
19312
+ raw = window.sessionStorage.getItem(this.#storageKey);
18452
19313
  } catch {
18453
19314
  return;
18454
19315
  }
18455
- if (this.#tracksVertical && typeof data.top === "number") {
18456
- this.element.scrollTop = data.top;
18457
- this.#lastTop = data.top;
18458
- }
18459
- if (this.#tracksHorizontal && typeof data.left === "number") {
18460
- this.element.scrollLeft = data.left;
18461
- this.#lastLeft = data.left;
19316
+ const data = parseStored(raw);
19317
+ if (data === null) return;
19318
+ this.#echoTop = null;
19319
+ this.#echoLeft = null;
19320
+ const top = storedOffset(data.top);
19321
+ const left = storedOffset(data.left);
19322
+ const options = { behavior: "instant" };
19323
+ let requested = false;
19324
+ if (this.#tracksVertical) {
19325
+ if (top !== null) {
19326
+ options.top = top;
19327
+ this.#lastTop = top;
19328
+ this.#capturedTop = true;
19329
+ requested = true;
19330
+ }
19331
+ } else if (data.top !== void 0) {
19332
+ this.#carried.top = data.top;
19333
+ }
19334
+ if (this.#tracksHorizontal) {
19335
+ if (left !== null) {
19336
+ options.left = left;
19337
+ this.#lastLeft = left;
19338
+ this.#capturedLeft = true;
19339
+ requested = true;
19340
+ }
19341
+ } else if (data.left !== void 0) {
19342
+ this.#carried.left = data.left;
18462
19343
  }
19344
+ if (!requested) return;
19345
+ const beforeTop = this.element.scrollTop;
19346
+ const beforeLeft = this.element.scrollLeft;
19347
+ this.element.scrollTo(options);
19348
+ if (this.element.scrollTop !== beforeTop) this.#echoTop = this.element.scrollTop;
19349
+ if (this.element.scrollLeft !== beforeLeft) this.#echoLeft = this.element.scrollLeft;
18463
19350
  }
18464
19351
  /** The `sessionStorage` key: explicit `key`, else the element `id`, else none. */
18465
19352
  #resolveKey() {
@@ -18473,10 +19360,11 @@ var ScrollRestoreController = class extends Controller {
18473
19360
  return this.axisValue === "horizontal" || this.axisValue === "both";
18474
19361
  }
18475
19362
  };
19363
+ var DEFAULT_OFFSET = 400;
18476
19364
  var ScrollVisibilityController = class extends Controller {
18477
19365
  static targets = ["element"];
18478
19366
  static values = {
18479
- offset: { type: Number, default: 400 },
19367
+ offset: { type: Number, default: DEFAULT_OFFSET },
18480
19368
  mode: { type: String, default: "offset" },
18481
19369
  focusSelector: { type: String, default: "" },
18482
19370
  root: { type: String, default: "" }
@@ -18494,8 +19382,32 @@ var ScrollVisibilityController = class extends Controller {
18494
19382
  * the window. Captured on connect so teardown detaches from the same source.
18495
19383
  */
18496
19384
  #scrollSource = window;
19385
+ /**
19386
+ * Gates the declaration callbacks to the connected window.
19387
+ *
19388
+ * Stimulus delivers a Value callback ahead of `connect()` and again for every
19389
+ * runtime change; without the gate, merely connecting would evaluate — and
19390
+ * announce — before `connect()` runs its own first reflection.
19391
+ */
19392
+ #connected = false;
19393
+ /** Validated threshold; a non-finite declaration reads as the default. */
19394
+ #offset = DEFAULT_OFFSET;
19395
+ /** Validated `root` selector; an unparsable declaration reads as absent. */
19396
+ #rootSelector = "";
19397
+ /** Validated `focusSelector`; an unparsable declaration reads as absent. */
19398
+ #focusSelector = "";
18497
19399
  /** Focus targets this instance lent a `tabindex` to. */
18498
19400
  #tabindex = new TabindexLoan();
19401
+ /**
19402
+ * The hide held back while the control itself owns focus.
19403
+ *
19404
+ * Completing the deferral re-runs the ordinary evaluation rather than applying
19405
+ * the stale decision: by the time focus leaves, the scroll position may have
19406
+ * moved back past the threshold.
19407
+ */
19408
+ #pendingHide = new BlurDeferral(() => {
19409
+ if (this.#connected) this.#evaluate();
19410
+ });
18499
19411
  #onScroll = () => {
18500
19412
  if (this.#rafId !== null) return;
18501
19413
  this.#rafId = requestAnimationFrame(() => {
@@ -18507,61 +19419,134 @@ var ScrollVisibilityController = class extends Controller {
18507
19419
  this.#scrollSource = this.#resolveScrollSource();
18508
19420
  this.#lastScrollY = this.#scrollY();
18509
19421
  this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
18510
- this.#evaluate();
19422
+ this.#evaluate(false);
19423
+ this.#connected = true;
18511
19424
  }
18512
19425
  disconnect() {
19426
+ this.#connected = false;
18513
19427
  this.#scrollSource.removeEventListener("scroll", this.#onScroll);
18514
19428
  if (this.#rafId !== null) {
18515
19429
  cancelAnimationFrame(this.#rafId);
18516
19430
  this.#rafId = null;
18517
19431
  }
19432
+ this.#pendingHide.releaseAll();
18518
19433
  this.#tabindex.returnAll();
18519
19434
  this.#visible = null;
18520
19435
  }
19436
+ /** Writes the current visibility onto a control that arrives after connect. */
19437
+ elementTargetConnected(element) {
19438
+ if (this.#visible !== null) element.hidden = !this.#visible;
19439
+ }
19440
+ /** Drops a held-back hide together with the control it was waiting on. */
19441
+ elementTargetDisconnected() {
19442
+ this.#pendingHide.releaseAll();
19443
+ }
19444
+ /**
19445
+ * Validates `offset` once, then re-renders.
19446
+ *
19447
+ * Re-renders when application code (or a Turbo morph) changes `offset` at
19448
+ * runtime. A declaration that is not a finite number reads as the default, so
19449
+ * the comparison path never sees `NaN` — which would answer `false` to every
19450
+ * comparison and strand the element (in `direction` mode, even the guarantee
19451
+ * that the very top always reveals).
19452
+ */
19453
+ offsetValueChanged() {
19454
+ this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
19455
+ if (this.#connected) this.#evaluate();
19456
+ }
19457
+ /** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */
19458
+ modeValueChanged() {
19459
+ if (this.#connected) this.#evaluate();
19460
+ }
19461
+ /** Validates `root` once so connect never parses a selector that throws. */
19462
+ rootValueChanged() {
19463
+ this.#rootSelector = this.#validSelector(this.rootValue);
19464
+ }
19465
+ /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
19466
+ focusSelectorValueChanged() {
19467
+ this.#focusSelector = this.#validSelector(this.focusSelectorValue);
19468
+ }
18521
19469
  /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
18522
19470
  toTop() {
18523
19471
  const behavior = prefersReducedMotion() ? "instant" : "smooth";
18524
19472
  this.#scrollSource.scrollTo({ top: 0, behavior });
18525
- if (this.focusSelectorValue) {
18526
- const target = document.querySelector(this.focusSelectorValue);
19473
+ if (this.#focusSelector) {
19474
+ const target = document.querySelector(this.#focusSelector);
18527
19475
  if (target) {
18528
19476
  this.#tabindex.lend(target);
18529
- target.focus();
19477
+ target.focus({ preventScroll: true });
18530
19478
  }
18531
19479
  }
18532
19480
  }
18533
- /** Decides the next visibility from the current scroll state and applies it. */
18534
- #evaluate() {
19481
+ /**
19482
+ * Decides the next visibility from the current scroll state and applies it.
19483
+ *
19484
+ * @param notify - whether a transition announces itself. The reflection
19485
+ * `connect()` performs is the current state, not a change.
19486
+ *
19487
+ * @stimeoRenderRoot
19488
+ */
19489
+ #evaluate(notify = true) {
18535
19490
  const y = this.#scrollY();
18536
19491
  let nextVisible;
18537
19492
  if (this.modeValue === "direction") {
18538
- if (y <= this.offsetValue) {
19493
+ if (y <= this.#offset) {
18539
19494
  nextVisible = true;
19495
+ } else if (y === this.#lastScrollY) {
19496
+ return;
18540
19497
  } else {
18541
19498
  nextVisible = y < this.#lastScrollY;
18542
19499
  }
18543
19500
  } else {
18544
- nextVisible = y > this.offsetValue;
19501
+ nextVisible = y > this.#offset;
18545
19502
  }
18546
19503
  this.#lastScrollY = y;
18547
- this.#setVisible(nextVisible);
19504
+ this.#setVisible(nextVisible, notify);
18548
19505
  }
18549
19506
  /** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */
18550
- #setVisible(next) {
19507
+ #setVisible(next, notify) {
18551
19508
  if (next === this.#visible) return;
19509
+ const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;
19510
+ if (focused) {
19511
+ this.#pendingHide.deferOnly(focused);
19512
+ return;
19513
+ }
18552
19514
  this.#visible = next;
18553
19515
  if (this.hasElementTarget) this.elementTarget.hidden = !next;
18554
19516
  this.element.setAttribute("data-state", next ? "visible" : "hidden");
18555
- this.dispatch("change", { detail: { visible: next } });
19517
+ if (notify) this.dispatch("change", { detail: { visible: next } });
18556
19518
  }
18557
19519
  /** Resolves the scroll source from `root` (falling back to the window). */
18558
19520
  #resolveScrollSource() {
18559
- if (this.rootValue) {
18560
- const root = document.querySelector(this.rootValue);
19521
+ if (this.#rootSelector) {
19522
+ const root = document.querySelector(this.#rootSelector);
18561
19523
  if (root) return root;
18562
19524
  }
18563
19525
  return window;
18564
19526
  }
19527
+ /**
19528
+ * The focus owner inside the target, or `null` when focus is elsewhere.
19529
+ *
19530
+ * `blur` does not bubble, so the deferral has to ride the focused element
19531
+ * itself: waiting on a container that never receives the event would hold the
19532
+ * hide forever.
19533
+ */
19534
+ #focusedWithin() {
19535
+ const focused = document.activeElement;
19536
+ if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
19537
+ return null;
19538
+ }
19539
+ /** Returns `declared` when it parses as a selector, and `""` when it does not. */
19540
+ #validSelector(declared) {
19541
+ if (declared.length > 0) {
19542
+ try {
19543
+ this.element.matches(declared);
19544
+ return declared;
19545
+ } catch {
19546
+ }
19547
+ }
19548
+ return "";
19549
+ }
18565
19550
  #scrollY() {
18566
19551
  if (this.#scrollSource === window) {
18567
19552
  return window.scrollY ?? window.pageYOffset ?? 0;
@@ -18973,16 +19958,16 @@ var ScrollspyController = class extends Controller {
18973
19958
  return value.substring(1) || null;
18974
19959
  }
18975
19960
  };
18976
- var DEFAULT_MIN2 = 0;
18977
- var DEFAULT_MAX2 = 100;
19961
+ var DEFAULT_MIN3 = 0;
19962
+ var DEFAULT_MAX3 = 100;
18978
19963
  var SeparatorController = class extends Controller {
18979
19964
  static values = {
18980
19965
  orientation: { type: String, default: "horizontal" },
18981
19966
  focusable: { type: Boolean, default: false },
18982
- min: { type: Number, default: DEFAULT_MIN2 },
18983
- max: { type: Number, default: DEFAULT_MAX2 },
19967
+ min: { type: Number, default: DEFAULT_MIN3 },
19968
+ max: { type: Number, default: DEFAULT_MAX3 },
18984
19969
  step: { type: Number, default: 1 },
18985
- value: { type: Number, default: DEFAULT_MIN2 }
19970
+ value: { type: Number, default: DEFAULT_MIN3 }
18986
19971
  };
18987
19972
  static actions = ["onKeydown"];
18988
19973
  static events = ["change"];
@@ -19103,8 +20088,8 @@ var SeparatorController = class extends Controller {
19103
20088
  }
19104
20089
  /** Finite, ordered range shared by rendering and keyboard stepping. */
19105
20090
  get #effectiveRange() {
19106
- const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN2;
19107
- const candidateMax = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX2;
20091
+ const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN3;
20092
+ const candidateMax = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX3;
19108
20093
  return {
19109
20094
  min,
19110
20095
  max: Math.max(min, candidateMax),
@@ -20129,11 +21114,6 @@ var StepIndicatorController = class extends Controller {
20129
21114
  };
20130
21115
  static actions = ["setCurrent"];
20131
21116
  static events = ["change"];
20132
- /**
20133
- * Whether the target callbacks may render. Stimulus reports the authored steps
20134
- * as connected before `connect()` and the remaining ones as disconnected after
20135
- * `disconnect()`, so this keeps a connect at one render pass, not one per step.
20136
- */
20137
21117
  /**
20138
21118
  * Collapses a batch of step callbacks — and a morph that swaps `current` with
20139
21119
  * them — into one repaint. Replacing a list of N steps delivers N callbacks, and
@@ -21423,35 +22403,48 @@ var TextareaAutosizeController = class extends Controller {
21423
22403
  };
21424
22404
  var MODES = ["light", "dark", "system"];
21425
22405
  var isMode = (value) => typeof value === "string" && MODES.includes(value);
22406
+ var DEFAULT_MODE = "system";
22407
+ var DEFAULT_TARGET = "html";
21426
22408
  var ThemeController = class extends Controller {
21427
22409
  static targets = ["option"];
21428
22410
  static values = {
21429
- mode: { type: String, default: "system" },
22411
+ mode: { type: String, default: DEFAULT_MODE },
21430
22412
  storageKey: { type: String, default: "stimeo-theme" },
21431
- target: { type: String, default: "html" }
22413
+ target: { type: String, default: DEFAULT_TARGET }
21432
22414
  };
21433
22415
  static actions = ["set", "toggle"];
21434
22416
  static events = ["change"];
21435
22417
  /** The OS dark-mode query, watched so `system` tracks live changes. */
21436
22418
  #media = null;
22419
+ /** Gate for the target callbacks, which Stimulus runs before `connect()`. */
22420
+ #connected = false;
22421
+ /** The `target` declaration after validation; the default when unparsable. */
22422
+ #targetSelector = DEFAULT_TARGET;
22423
+ /** Owns the single Tab stop across the option set (APG radiogroup). */
22424
+ #roving = new RovingTabindex(() => this.optionTargets);
22425
+ /**
22426
+ * The pair last reported, so a move can be told from a repeat. Neither side is
22427
+ * readable after the fact — assigning the Value updates the mode before any
22428
+ * comparison, and the OS query has already flipped by the time it notifies —
22429
+ * so what was reported has to be kept rather than recomputed.
22430
+ */
22431
+ #published = {
22432
+ mode: DEFAULT_MODE,
22433
+ resolved: "light"
22434
+ };
21437
22435
  /** Re-resolves while in `system` mode when the OS preference flips. */
21438
22436
  #onMediaChange = () => {
21439
- if (this.modeValue === "system") {
21440
- this.#applyTheme();
21441
- this.#syncControls();
21442
- this.#dispatchChange();
21443
- }
22437
+ if (this.#mode !== "system") return;
22438
+ this.#commit();
21444
22439
  };
21445
22440
  /** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
21446
22441
  #onKeydown = (event) => {
21447
22442
  if (event.defaultPrevented) return;
21448
22443
  if (isReservedArrowChord(event)) return;
21449
22444
  const options = this.optionTargets;
21450
- if (options.length === 0) return;
21451
22445
  const target = event.target;
21452
22446
  const current = options.indexOf(target);
21453
22447
  if (current === -1) return;
21454
- const last = options.length - 1;
21455
22448
  let next = current;
21456
22449
  const step = logicalArrowStep(event.key, this.element);
21457
22450
  switch (event.key) {
@@ -21459,13 +22452,12 @@ var ThemeController = class extends Controller {
21459
22452
  case "ArrowRight":
21460
22453
  case "ArrowUp":
21461
22454
  case "ArrowLeft":
21462
- next = step === 1 ? current === last ? 0 : current + 1 : current === 0 ? last : current - 1;
22455
+ next = rovingMove(current, options.length, step, "wrap");
21463
22456
  break;
21464
22457
  case "Home":
21465
- next = 0;
21466
- break;
21467
22458
  case "End":
21468
- next = last;
22459
+ if (hasModifierChord(event)) return;
22460
+ next = event.key === "Home" ? 0 : options.length - 1;
21469
22461
  break;
21470
22462
  default:
21471
22463
  return;
@@ -21474,25 +22466,58 @@ var ThemeController = class extends Controller {
21474
22466
  const option = options[next];
21475
22467
  if (!option) return;
21476
22468
  option.focus();
21477
- this.#setMode(this.#optionMode(option));
22469
+ const mode = this.#optionMode(option);
22470
+ if (mode) this.#setMode(mode);
21478
22471
  };
21479
22472
  connect() {
21480
22473
  const stored = this.#readStored();
21481
22474
  if (stored) this.modeValue = stored;
21482
22475
  this.#media = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
21483
22476
  this.#media?.addEventListener("change", this.#onMediaChange);
21484
- if (this.hasOptionTarget) this.element.addEventListener("keydown", this.#onKeydown);
22477
+ this.element.addEventListener("keydown", this.#onKeydown);
22478
+ this.#connected = true;
21485
22479
  this.#applyTheme();
21486
22480
  this.#syncControls();
22481
+ this.#published = this.#current;
21487
22482
  }
21488
22483
  disconnect() {
22484
+ this.#connected = false;
21489
22485
  this.#media?.removeEventListener("change", this.#onMediaChange);
21490
22486
  this.element.removeEventListener("keydown", this.#onKeydown);
21491
22487
  }
21492
- /** Selects an explicit mode from the `mode` action param (radiogroup option). */
22488
+ /** Validates the `target` declaration once, so the render path never parses. */
22489
+ targetValueChanged() {
22490
+ const selector = this.targetValue;
22491
+ if (selector.length > 0) {
22492
+ try {
22493
+ this.element.matches(selector);
22494
+ this.#targetSelector = selector;
22495
+ return;
22496
+ } catch {
22497
+ }
22498
+ }
22499
+ this.#targetSelector = DEFAULT_TARGET;
22500
+ }
22501
+ /** Re-derives the single Tab stop and ARIA for an option set that changed. */
22502
+ optionTargetConnected() {
22503
+ if (this.#connected) this.#syncControls();
22504
+ }
22505
+ /** Re-derives them again when an option leaves, so a Tab stop always remains. */
22506
+ optionTargetDisconnected() {
22507
+ if (this.#connected) this.#syncControls();
22508
+ }
22509
+ /**
22510
+ * Selects the mode the activated option declares.
22511
+ *
22512
+ * Read through {@link ThemeController.#optionMode}, the same lane that decides
22513
+ * which option is checked, so the two can never disagree about what an option
22514
+ * declares.
22515
+ */
21493
22516
  set(event) {
21494
- const mode = event.params?.mode;
21495
- if (isMode(mode)) this.#setMode(mode);
22517
+ const option = event.currentTarget;
22518
+ if (!(option instanceof HTMLElement)) return;
22519
+ const mode = this.#optionMode(option);
22520
+ if (mode) this.#setMode(mode);
21496
22521
  }
21497
22522
  /** Toggles light↔dark for the 2-value single-button contract. */
21498
22523
  toggle() {
@@ -21502,9 +22527,26 @@ var ThemeController = class extends Controller {
21502
22527
  #setMode(mode) {
21503
22528
  this.modeValue = mode;
21504
22529
  this.#writeStored(mode);
22530
+ this.#commit();
22531
+ }
22532
+ /**
22533
+ * Applies the current mode and reports it, but reports only a real move: the
22534
+ * event means "the selection or the effective theme moved", so re-choosing the
22535
+ * option already chosen is not one.
22536
+ */
22537
+ #commit() {
21505
22538
  this.#applyTheme();
21506
22539
  this.#syncControls();
21507
- this.#dispatchChange();
22540
+ const next = this.#current;
22541
+ const last = this.#published;
22542
+ this.#published = next;
22543
+ if (last.mode !== next.mode || last.resolved !== next.resolved) {
22544
+ this.dispatch("change", { detail: { ...next } });
22545
+ }
22546
+ }
22547
+ /** The pair the `change` detail carries, read from current state. */
22548
+ get #current() {
22549
+ return { mode: this.#mode, resolved: this.#resolved() };
21508
22550
  }
21509
22551
  /** Writes `data-theme` + `color-scheme` (the resolved theme) onto the target. */
21510
22552
  #applyTheme() {
@@ -21518,39 +22560,45 @@ var ThemeController = class extends Controller {
21518
22560
  #syncControls() {
21519
22561
  const options = this.optionTargets;
21520
22562
  if (options.length > 0) {
21521
- let hasTabbable = false;
21522
- for (const option of options) {
21523
- const selected = this.#optionMode(option) === this.modeValue;
21524
- option.setAttribute("aria-checked", String(selected));
21525
- option.tabIndex = selected ? 0 : -1;
21526
- hasTabbable ||= selected;
21527
- }
21528
- const first = options[0];
21529
- if (!hasTabbable && first) first.tabIndex = 0;
22563
+ const mode = this.#mode;
22564
+ let selected = -1;
22565
+ options.forEach((option, index) => {
22566
+ const isSelected = this.#optionMode(option) === mode;
22567
+ option.setAttribute("aria-checked", String(isSelected));
22568
+ if (isSelected && selected === -1) selected = index;
22569
+ });
22570
+ this.#roving.setActive(selected === -1 ? 0 : selected, { items: options });
21530
22571
  return;
21531
22572
  }
21532
- this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
22573
+ if (this.#isToggleButton) {
22574
+ this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
22575
+ }
21533
22576
  }
21534
- /** Emits `change` with the selected mode and the resolved theme. */
21535
- #dispatchChange() {
21536
- this.dispatch("change", { detail: { mode: this.modeValue, resolved: this.#resolved() } });
22577
+ /** Whether the controller element is the button of the 2-value contract. */
22578
+ get #isToggleButton() {
22579
+ return this.element.tagName === "BUTTON" || this.element.getAttribute("role") === "button";
22580
+ }
22581
+ /** The selected mode after validation; an unreadable declaration is the default. */
22582
+ get #mode() {
22583
+ return isMode(this.modeValue) ? this.modeValue : DEFAULT_MODE;
21537
22584
  }
21538
22585
  /** The effective theme: the OS preference when `system`, else the mode itself. */
21539
22586
  #resolved() {
21540
- if (this.modeValue === "dark") return "dark";
21541
- if (this.modeValue === "light") return "light";
22587
+ const mode = this.#mode;
22588
+ if (mode === "dark") return "dark";
22589
+ if (mode === "light") return "light";
21542
22590
  return this.#media?.matches ? "dark" : "light";
21543
22591
  }
21544
- /** Reads an option's mode from its action param attribute. */
22592
+ /** An option's mode from its `data-value`, or `null` when that is not one of the three. */
21545
22593
  #optionMode(option) {
21546
- const mode = option.getAttribute("data-stimeo--theme-mode-param");
21547
- return isMode(mode) ? mode : "system";
22594
+ const mode = option.getAttribute("data-value");
22595
+ return isMode(mode) ? mode : null;
21548
22596
  }
21549
22597
  /** Resolves the state-hook target (`<html>` by default). */
21550
22598
  #targetElement() {
21551
- if (this.targetValue === "html" || this.targetValue === ":root")
21552
- return document.documentElement;
21553
- return document.querySelector(this.targetValue);
22599
+ const selector = this.#targetSelector;
22600
+ if (selector === DEFAULT_TARGET || selector === ":root") return document.documentElement;
22601
+ return document.querySelector(selector);
21554
22602
  }
21555
22603
  /** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
21556
22604
  #readStored() {
@@ -22007,8 +23055,8 @@ var ToastController = class extends Controller {
22007
23055
  }
22008
23056
  /**
22009
23057
  * Stimulus lifecycle callback triggered automatically when a new item target
22010
- * enters the DOM. Perfectly handles dynamic client-side injections and server-side
22011
- * Turbo Stream appends alike.
23058
+ * enters the DOM, from a client-side injection or a Turbo Stream append alike. An
23059
+ * item already leaving, or parented outside `list`, is skipped.
22012
23060
  */
22013
23061
  itemTargetConnected(element) {
22014
23062
  this.enforceMaxLimit();
@@ -22974,12 +24022,21 @@ var TransitionController = class extends Controller {
22974
24022
  static events = ["entered", "left"];
22975
24023
  /** Owns the cancellable completion wait (terminal events + bounded fallback). */
22976
24024
  #transition = new TransitionCompletion();
24025
+ /** Rewinds a half-applied stage before Turbo copies the page into its snapshot. */
24026
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
22977
24027
  #rafId = null;
24028
+ /**
24029
+ * Stage classes this controller put on the element. Removing by declaration
24030
+ * instead would take a token the consumer also authored, and would strand the
24031
+ * token that was applied when a Value changes mid-transition.
24032
+ */
24033
+ #staged = /* @__PURE__ */ new Set();
22978
24034
  connect() {
22979
- this.#strip();
22980
- this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
24035
+ this.#beforeCache.activate();
24036
+ this.#settleState();
22981
24037
  }
22982
24038
  disconnect() {
24039
+ this.#beforeCache.deactivate();
22983
24040
  this.#cancel();
22984
24041
  }
22985
24042
  /** Shows the element with the enter transition. */
@@ -23009,6 +24066,7 @@ var TransitionController = class extends Controller {
23009
24066
  const from = isEnter ? this.enterFromValue : this.leaveFromValue;
23010
24067
  const to = isEnter ? this.enterToValue : this.leaveToValue;
23011
24068
  this.#add(base, from);
24069
+ void this.element.offsetWidth;
23012
24070
  this.#rafId = this.#raf(() => {
23013
24071
  this.#rafId = null;
23014
24072
  this.#remove(from);
@@ -23030,6 +24088,21 @@ var TransitionController = class extends Controller {
23030
24088
  this.dispatch("left", { detail: {} });
23031
24089
  }
23032
24090
  }
24091
+ /** Writes the state hook the element's visibility implies. */
24092
+ #settleState() {
24093
+ this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
24094
+ }
24095
+ /**
24096
+ * Returns the element to a settled state before Turbo copies the page.
24097
+ *
24098
+ * The snapshot is taken while the controller is still connected, so stripping on
24099
+ * the next `connect()` would only repair the page after it has been painted from
24100
+ * the cache. The pass is silent: `connect()` derives the state again on restore.
24101
+ */
24102
+ #rewindForCache() {
24103
+ this.#cancel();
24104
+ this.#settleState();
24105
+ }
23033
24106
  /** Cancels any in-flight transition (interruption / teardown). */
23034
24107
  #cancel() {
23035
24108
  if (this.#rafId !== null) {
@@ -23039,24 +24112,34 @@ var TransitionController = class extends Controller {
23039
24112
  this.#transition.cancel();
23040
24113
  this.#strip();
23041
24114
  }
24115
+ /**
24116
+ * Applies the stage tokens this controller does not already find on the
24117
+ * element, and claims exactly those.
24118
+ *
24119
+ * A token already on the element is left unclaimed: it is either the consumer's
24120
+ * standing class or one an earlier stage of this transition already claimed, and
24121
+ * in neither case may this call take ownership of it. That is what keeps a
24122
+ * standing class the consumer also named as a stage Value; the cost is that such
24123
+ * a token cannot be staged, so the property it drives resolves from their CSS.
24124
+ */
23042
24125
  #add(...lists) {
23043
- const tokens = lists.flatMap(tokensOf);
23044
- if (tokens.length > 0) this.element.classList.add(...tokens);
24126
+ for (const token of lists.flatMap(tokensOf)) {
24127
+ if (this.element.classList.contains(token)) continue;
24128
+ this.element.classList.add(token);
24129
+ this.#staged.add(token);
24130
+ }
23045
24131
  }
24132
+ /** Drops the named tokens that are this controller's to drop. */
23046
24133
  #remove(...lists) {
23047
- const tokens = lists.flatMap(tokensOf);
23048
- if (tokens.length > 0) this.element.classList.remove(...tokens);
24134
+ for (const token of lists.flatMap(tokensOf)) {
24135
+ if (!this.#staged.delete(token)) continue;
24136
+ this.element.classList.remove(token);
24137
+ }
23049
24138
  }
23050
- /** Removes every stage class so no half-applied state lingers. */
24139
+ /** Removes every stage class this controller applied, so none lingers. */
23051
24140
  #strip() {
23052
- this.#remove(
23053
- this.enterValue,
23054
- this.enterFromValue,
23055
- this.enterToValue,
23056
- this.leaveValue,
23057
- this.leaveFromValue,
23058
- this.leaveToValue
23059
- );
24141
+ this.element.classList.remove(...this.#staged);
24142
+ this.#staged.clear();
23060
24143
  }
23061
24144
  #raf(callback) {
23062
24145
  if (typeof window.requestAnimationFrame === "function") {