stimeo-ui 0.9.0 → 0.11.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/dist/cable/index.js +226 -43
  4. data/dist/controllers/alert_dialog_controller.js +17 -7
  5. data/dist/controllers/announcer_controller.js +1 -1
  6. data/dist/controllers/bulk_select_controller.js +2 -2
  7. data/dist/controllers/collapsible_controller.js +1 -1
  8. data/dist/controllers/command_palette_controller.js +15 -5
  9. data/dist/controllers/confirm_controller.js +15 -5
  10. data/dist/controllers/countdown_controller.js +1 -1
  11. data/dist/controllers/data_grid_controller.js +1 -1
  12. data/dist/controllers/dialog_controller.js +15 -5
  13. data/dist/controllers/direct_upload_controller.js +1 -5
  14. data/dist/controllers/drawer_controller.js +17 -7
  15. data/dist/controllers/focus_controller.js +45 -7
  16. data/dist/controllers/form_field_controller.js +1 -1
  17. data/dist/controllers/menubar_controller.js +2 -2
  18. data/dist/controllers/multi_select_controller.js +1 -1
  19. data/dist/controllers/overflow_menu_controller.js +2 -2
  20. data/dist/controllers/password_reveal_controller.js +102 -3
  21. data/dist/controllers/password_strength_controller.js +287 -42
  22. data/dist/controllers/pointer_drag_controller.js +64 -18
  23. data/dist/controllers/portal_controller.js +32 -11
  24. data/dist/controllers/preview_guard_controller.js +180 -20
  25. data/dist/controllers/resizable_controller.js +1 -1
  26. data/dist/controllers/roving_controller.js +127 -14
  27. data/dist/controllers/scroll_restore_controller.js +127 -27
  28. data/dist/controllers/scroll_visibility_controller.js +167 -14
  29. data/dist/controllers/sidebar_controller.js +15 -5
  30. data/dist/controllers/step_indicator_controller.js +0 -5
  31. data/dist/controllers/stick_to_bottom_controller.js +149 -28
  32. data/dist/controllers/theme_controller.js +151 -40
  33. data/dist/controllers/toast_controller.js +2 -2
  34. data/dist/controllers/transition_controller.js +79 -15
  35. data/dist/index.js +1188 -318
  36. data/dist/positioning/index.js +84 -25
  37. data/lib/stimeo/ui/version.rb +1 -1
  38. 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) {
@@ -1693,8 +1706,8 @@ var BulkSelectController = class extends Controller {
1693
1706
  static events = ["change", "reconcile"];
1694
1707
  /** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a
1695
1708
  * `connect()` over markup that already carries the attribute rehydrates the
1696
- * mode — a morph, a Turbo Stream, or a server that renders it back. A restore
1697
- * visit serves the server's markup instead, so the mode does not survive one. */
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. */
1698
1711
  #allPagesMode = false;
1699
1712
  /** Last emitted figures, so a recompute reports only on a real change. */
1700
1713
  #lastCount = -1;
@@ -3497,7 +3510,7 @@ var CollapsibleController = class extends Controller {
3497
3510
  * `open` Value disagrees. An already-open region remains open without a
3498
3511
  * close/reopen cycle. The Value only seeds a genuinely fresh render where no
3499
3512
  * state attribute is present yet; any opening animation in that case belongs to
3500
- * the consumer's CSS. Mirrors `sidebar`'s `#restoreCollapsed`.
3513
+ * the consumer's CSS.
3501
3514
  */
3502
3515
  connect() {
3503
3516
  this.#connected = true;
@@ -5421,7 +5434,7 @@ var CountdownController = class extends Controller {
5421
5434
  #pausedAmount = 0;
5422
5435
  /**
5423
5436
  * The amount the slots are currently showing, floored to the second they render.
5424
- * 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 —
5425
5438
  * is what a pause has to preserve: storing the fraction behind the display instead
5426
5439
  * makes the first tick after a resume step by two units.
5427
5440
  */
@@ -6177,7 +6190,7 @@ var DataGridController = class extends Controller {
6177
6190
  const row = cell.closest("[role='row']");
6178
6191
  if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
6179
6192
  }
6180
- /** Shared sort logic for both click and keyboard activation. */
6193
+ /** Cycles a header's sort on keyboard activation and emits `sort`. */
6181
6194
  #cycleSort(header) {
6182
6195
  const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
6183
6196
  for (const other of this.columnHeaderTargets) {
@@ -6945,10 +6958,6 @@ var DirectUploadController = class extends Controller {
6945
6958
  this.#rows.delete(id);
6946
6959
  }
6947
6960
  }
6948
- /**
6949
- * Returns the widget to its pre-upload state just before Turbo caches the
6950
- * page, so the snapshot never replays rows for uploads that cannot resume.
6951
- */
6952
6961
  /**
6953
6962
  * Rewinds for the snapshot and reports what that discarded. An upload in flight
6954
6963
  * cannot survive the navigation, so a consumer mirroring the rows would keep a
@@ -6987,7 +6996,7 @@ var DirectUploadController = class extends Controller {
6987
6996
  #detail(event) {
6988
6997
  return event.detail ?? {};
6989
6998
  }
6990
- /** The file name every ActiveStorage `direct-upload:*` event carries. */
6999
+ /** The event's file name, or `""` when it carries none. */
6991
7000
  #name(detail) {
6992
7001
  return detail.file?.name ?? "";
6993
7002
  }
@@ -7361,8 +7370,8 @@ var DrawerController = class extends Controller {
7361
7370
  * rather than being re-derived from the declarative `open` Value (which would
7362
7371
  * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
7363
7372
  * render. We normalize to a clean closed baseline first so {@link open} runs its
7364
- * full reveal + trap activation — the {@link FocusTrap} is a fresh instance
7365
- * 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.
7366
7375
  */
7367
7376
  connect() {
7368
7377
  this.#connected = true;
@@ -8756,13 +8765,39 @@ var FocusController = class extends Controller {
8756
8765
  initialFocus: () => this.hasInitialTarget ? this.initialTarget : null,
8757
8766
  onEscape: () => this.deactivate()
8758
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
+ });
8759
8789
  /** Stimulus drives activation from the `trap` value (also fires on connect). */
8760
8790
  trapValueChanged() {
8761
8791
  if (this.trapValue) this.#activate();
8762
8792
  else this.#deactivate();
8763
8793
  }
8794
+ connect() {
8795
+ this.#beforeCache.activate();
8796
+ }
8764
8797
  disconnect() {
8765
8798
  this.#trap.deactivate({ restoreFocus: false });
8799
+ this.#beforeCache.deactivate();
8800
+ this.#active = false;
8766
8801
  this.element.removeAttribute("data-focus-trapped");
8767
8802
  }
8768
8803
  /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */
@@ -8776,13 +8811,15 @@ var FocusController = class extends Controller {
8776
8811
  this.#deactivate();
8777
8812
  }
8778
8813
  #activate() {
8779
- if (this.#trap.active) return;
8814
+ if (this.#active) return;
8815
+ this.#active = true;
8780
8816
  this.#trap.activate();
8781
8817
  this.element.setAttribute("data-focus-trapped", "true");
8782
8818
  this.dispatch("activate", { detail: {} });
8783
8819
  }
8784
8820
  #deactivate() {
8785
- if (!this.#trap.active) return;
8821
+ if (!this.#active) return;
8822
+ this.#active = false;
8786
8823
  this.#trap.deactivate({ restoreFocus: this.restoreValue });
8787
8824
  this.element.removeAttribute("data-focus-trapped");
8788
8825
  this.dispatch("deactivate", { detail: {} });
@@ -8800,7 +8837,7 @@ var FormFieldController = class _FormFieldController extends Controller {
8800
8837
  static #INVALID_ATTR = "data-stimeo--form-field-invalid";
8801
8838
  /** Collapses one target/morph batch into one silent ARIA reconciliation. */
8802
8839
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
8803
- /** ARIA ownership is scoped to the current singular control target. */
8840
+ /** Returns borrowed control ARIA before Turbo snapshots the page. */
8804
8841
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
8805
8842
  #ariaDescribedBy = new AttributeLease("aria-describedby");
8806
8843
  #ariaErrorMessage = new AttributeLease("aria-errormessage");
@@ -11687,8 +11724,8 @@ var MenubarController = class extends Controller {
11687
11724
  * being hidden itself, a menu can stay visible after its owning top was removed,
11688
11725
  * an open pair can appear from a morph with no layer registered for it, and the
11689
11726
  * Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
11690
- * all. Nothing is remembered between calls (except which side of the Escape stack
11691
- * 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
11692
11729
  * same whichever mutation arrived and calling it more often than needed is free.
11693
11730
  */
11694
11731
  #reconcile() {
@@ -12802,7 +12839,7 @@ var MultiSelectController = class extends Controller {
12802
12839
  get #values() {
12803
12840
  return this.#selectedOptions.map((option) => this.#optionValue(option));
12804
12841
  }
12805
- /** 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. */
12806
12843
  get #selectionLimit() {
12807
12844
  if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
12808
12845
  return Math.max(1, Math.floor(this.maxValue));
@@ -14957,8 +14994,8 @@ var OverflowMenuController = class extends Controller {
14957
14994
  *
14958
14995
  * With no known child at all (a fresh instance connecting to markup that already
14959
14996
  * holds banked items), a fully-banked snapshot's inert boundary preserves whether an
14960
- * unindexed run was prepended or appended before connect. Older or server-rendered
14961
- * 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
14962
14999
  * offset, the inverse of the move that banked it.
14963
15000
  */
14964
15001
  #merge(bar, banked, boundaryAt) {
@@ -15370,6 +15407,7 @@ var PaginationController = class _PaginationController extends Controller {
15370
15407
  return button.hasAttribute(_PaginationController.#BOUNDARY_ATTR);
15371
15408
  }
15372
15409
  };
15410
+ var MAX_DELAY = 2 ** 31 - 1;
15373
15411
  var PasswordRevealController = class extends Controller {
15374
15412
  static targets = ["input", "toggle"];
15375
15413
  static values = {
@@ -15379,11 +15417,54 @@ var PasswordRevealController = class extends Controller {
15379
15417
  static events = ["toggle"];
15380
15418
  /** Auto re-mask timer; torn down on disconnect. */
15381
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());
15382
15432
  connect() {
15383
- 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);
15384
15438
  }
15385
15439
  disconnect() {
15440
+ this.#connected = false;
15386
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);
15387
15468
  }
15388
15469
  /** Toggles the input between masked and revealed. Bound via `data-action`. */
15389
15470
  toggle() {
@@ -15412,11 +15493,37 @@ var PasswordRevealController = class extends Controller {
15412
15493
  }
15413
15494
  this.#reflect(visible);
15414
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) {
15415
15500
  this.#timers.clearAll();
15416
- if (visible && this.autoHideValue > 0) {
15417
- 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);
15418
15504
  }
15419
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
+ }
15420
15527
  /** Reflects the visible state onto `aria-pressed` and `data-state`. */
15421
15528
  #reflect(visible) {
15422
15529
  if (this.hasToggleTarget) {
@@ -15430,6 +15537,7 @@ var DEFAULT_LEVELS = ["weak", "fair", "good", "strong"];
15430
15537
  var LENGTH_MILESTONES = [8, 12, 16];
15431
15538
  var MAX_POINTS = LENGTH_MILESTONES.length + (CLASS_PATTERNS.length - 1);
15432
15539
  var STRENGTH_BANDS = ["weak", "fair", "good", "strong"];
15540
+ var MIN_LEVELS = 2;
15433
15541
  var PasswordStrengthController = class _PasswordStrengthController extends Controller {
15434
15542
  static targets = ["input", "meter", "label"];
15435
15543
  static values = {
@@ -15437,50 +15545,143 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
15437
15545
  // A JSON list read through `parseStringList` rather than Stimulus's `Array`
15438
15546
  // type: that reader throws out of the value observer before any callback
15439
15547
  // runs, so one malformed attribute would stop the meter connecting.
15440
- levels: { type: String, default: "" }
15548
+ levels: { type: String, default: "" },
15549
+ announceText: { type: String, default: "" }
15441
15550
  };
15442
- static actions = ["evaluate"];
15443
- static events = ["change"];
15444
- /** 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. */
15445
15554
  static #announceDelay = 200;
15446
15555
  #timers = new SafeTimeout();
15556
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
15557
+ #repaint = new MicrotaskCoalescer(() => this.#reconcile());
15558
+ #levels = [...DEFAULT_LEVELS];
15447
15559
  #announceId = null;
15560
+ #announcedLevel = null;
15561
+ #externalScore = null;
15562
+ #lastDetail = null;
15563
+ /** Reflects the current DOM state and opens the reconciliation window. */
15448
15564
  connect() {
15449
- this.#update({ announce: false });
15565
+ this.#repaint.activate();
15566
+ this.#beforeCache.activate();
15567
+ this.#lastDetail = this.#detail(this.#render());
15450
15568
  }
15569
+ /** Releases the reconciliation window, the cache subscription and the debounce. */
15451
15570
  disconnect() {
15452
- this.#timers.clearAll();
15453
- 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;
15454
15577
  }
15455
15578
  /** Re-evaluates strength from the input. Bound via `data-action` (`input`). */
15456
15579
  evaluate() {
15457
- 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
+ }
15458
15651
  }
15459
15652
  /**
15460
- * Recomputes the strength. The meter ARIA, data hooks, the custom property and
15461
- * the `change` event apply immediately; the live-region label text is debounced
15462
- * 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
15463
15657
  */
15464
- #update(options = {}) {
15465
- const password = this.hasInputTarget ? this.inputTarget.value : "";
15466
- const labels = parseStringList(this.levelsValue, DEFAULT_LEVELS);
15467
- const max = labels.length;
15468
- const score = this.#score(password, max);
15469
- 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);
15470
15664
  this.#reflectMeter(score, max);
15471
- this.#reflectRoot(score, max);
15472
- if (options.announce === false) {
15473
- this.#writeLabel(label);
15474
- 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)));
15475
15683
  }
15476
- this.dispatch("change", {
15477
- detail: { score, level: label, max, meetsMin: score > 0 && score >= this.minScoreValue }
15478
- });
15479
- if (this.#announceId !== null) this.#timers.clear(this.#announceId);
15480
- this.#announceId = this.#timers.set(() => {
15481
- this.#writeLabel(label);
15482
- this.#announceId = null;
15483
- }, _PasswordStrengthController.#announceDelay);
15684
+ return this.#score(this.hasInputTarget ? this.inputTarget.value : "", max);
15484
15685
  }
15485
15686
  /** Syncs the meter target's ARIA value attributes (`0..levels.length`). */
15486
15687
  #reflectMeter(score, max) {
@@ -15494,25 +15695,27 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
15494
15695
  * empty), the `data-below-min` hook when the score is under `minScore`, and the
15495
15696
  * `0–1` fill the consumer's CSS turns into the bar width.
15496
15697
  */
15497
- #reflectRoot(score, max) {
15498
- const band = this.#band(score, max);
15698
+ #reflectRoot(score, max, band, meetsMin) {
15499
15699
  this.#toggle("data-strength", band, band.length > 0);
15500
- this.#toggle("data-below-min", "true", score > 0 && score < this.minScoreValue);
15501
- const ratio = max > 0 ? score / max : 0;
15502
- 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));
15503
15702
  }
15504
- #writeLabel(label) {
15505
- 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;
15506
15707
  }
15507
15708
  /**
15508
15709
  * Locale-independent styling band (one of {@link STRENGTH_BANDS}) for `score`
15509
- * out of `max`. Empty input → `""`. Quantizes the `score/max` ratio into the
15510
- * 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.
15511
15714
  */
15512
15715
  #band(score, max) {
15513
- if (score <= 0 || max <= 0) return "";
15514
- const index = Math.ceil(score / max * STRENGTH_BANDS.length) - 1;
15515
- 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];
15516
15719
  }
15517
15720
  /** Sets `name` to `value` when `on`, else removes it (value/presence data hook). */
15518
15721
  #toggle(name, value, on) {
@@ -15522,6 +15725,11 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
15522
15725
  this.element.removeAttribute(name);
15523
15726
  }
15524
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
+ }
15525
15733
  /**
15526
15734
  * Lightweight zero-dependency strength heuristic returning an integer in
15527
15735
  * `[0, max]` (`max` = number of levels). Empty input is `0` (no level); any
@@ -15531,7 +15739,7 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
15531
15739
  * alone cannot mask trivial repetition.
15532
15740
  */
15533
15741
  #score(password, max) {
15534
- if (password.length === 0 || max === 0) return 0;
15742
+ if (password.length === 0) return 0;
15535
15743
  let points = 0;
15536
15744
  for (const milestone of LENGTH_MILESTONES) {
15537
15745
  if (password.length >= milestone) points += 1;
@@ -15541,6 +15749,64 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
15541
15749
  const bucketed = Math.round(points / MAX_POINTS * max);
15542
15750
  return Math.min(max, Math.max(1, bucketed));
15543
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
+ }
15544
15810
  };
15545
15811
 
15546
15812
  // src/utils/safe_storage.ts
@@ -16023,6 +16289,7 @@ var PersistController = class extends Controller {
16023
16289
  this.dispatch("error", { detail: { key, operation, reason } });
16024
16290
  }
16025
16291
  };
16292
+ var NATIVE_KEY_OWNERS = "input, textarea, select, button, a[href], summary, [contenteditable]";
16026
16293
  var PointerDragController = class _PointerDragController extends Controller {
16027
16294
  static targets = ["handle"];
16028
16295
  static values = {
@@ -16032,6 +16299,7 @@ var PointerDragController = class _PointerDragController extends Controller {
16032
16299
  disabled: { type: Boolean, default: false },
16033
16300
  follow: { type: Boolean, default: false }
16034
16301
  };
16302
+ static actions = ["reset"];
16035
16303
  static events = ["start", "move", "end", "cancel"];
16036
16304
  /** Marker attribute recording that this controller set the touch-action. */
16037
16305
  static #TOUCH_ACTION_MARKER = "data-pointer-drag-touch-action";
@@ -16065,23 +16333,49 @@ var PointerDragController = class _PointerDragController extends Controller {
16065
16333
  }
16066
16334
  this.#teardown();
16067
16335
  }
16068
- /** Prepares handles added at runtime (touch-action + focusability). */
16336
+ /**
16337
+ * Returns the element to its origin: drops the committed follow offset and the
16338
+ * inline `translate` that carries it.
16339
+ *
16340
+ * In follow mode the inline `translate` belongs to this controller, and the
16341
+ * committed offset lives in a field the DOM cannot reach — so a consumer that
16342
+ * wants the element back at the start, or that has written a position of its
16343
+ * own, needs this to make the two agree again. An in-flight drag is cancelled
16344
+ * first, or its deltas would land on top of the offset just cleared.
16345
+ */
16346
+ reset() {
16347
+ const live = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
16348
+ if (this.#pointer || this.#keyboard) this.#teardownSessions();
16349
+ if (live) this.#dispatchCancel(live);
16350
+ this.#followBase = { x: 0, y: 0 };
16351
+ this.#applyFollow(0, 0);
16352
+ }
16069
16353
  handleTargetConnected(handle) {
16354
+ if (handle !== this.element) this.#restoreHandle(this.element);
16070
16355
  this.#prepareHandle(handle);
16071
16356
  }
16072
16357
  handleTargetDisconnected(handle) {
16073
- if (!this.element.contains(handle)) {
16074
- if (this.#pointer?.handle === handle) this.#endPointerSession();
16075
- if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
16076
- }
16077
16358
  this.#restoreHandle(handle);
16359
+ if (this.element.contains(handle)) return;
16360
+ const interrupted = this.#pointer?.handle === handle && this.#pointer.started ? this.#pointer.pointerType : this.#keyboard?.handle === handle ? "keyboard" : null;
16361
+ if (this.#pointer?.handle === handle) this.#endPointerSession();
16362
+ if (this.#keyboard?.handle === handle) this.#clearKeyboardSession();
16363
+ if (!this.hasHandleTarget) this.#prepareHandle(this.element);
16364
+ if (interrupted) this.#dispatchCancel(interrupted);
16078
16365
  }
16079
- /** Re-derives the handles' touch-action when the axis changes. */
16080
- axisValueChanged() {
16366
+ /**
16367
+ * Re-derives the handles' touch-action when the axis changes.
16368
+ *
16369
+ * Two layers, like the `tabindex` loan: the marker says the value was once
16370
+ * ours, and the current inline value says it still is. A consumer that wrote
16371
+ * its own `touch-action` after connect keeps it.
16372
+ */
16373
+ axisValueChanged(_value, previousValue) {
16374
+ const lent = this.#touchActionFor(previousValue ?? this.axisValue);
16081
16375
  for (const handle of this.#handles()) {
16082
- if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
16083
- handle.style.touchAction = this.#touchActionForAxis();
16084
- }
16376
+ if (!handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) continue;
16377
+ if (handle.style.touchAction !== lent) continue;
16378
+ handle.style.touchAction = this.#touchActionForAxis();
16085
16379
  }
16086
16380
  }
16087
16381
  /** Cancels any in-flight session when the controller is disabled mid-drag. */
@@ -16162,9 +16456,10 @@ var PointerDragController = class _PointerDragController extends Controller {
16162
16456
  const handle = this.#handleFor(event.target);
16163
16457
  if (!handle) return;
16164
16458
  if (event.defaultPrevented) return;
16459
+ if (event.isComposing) return;
16460
+ if (this.#ownsNativeKeys(event.target, handle)) return;
16165
16461
  if (isReservedArrowChord(event)) return;
16166
16462
  if (event.key === "Escape") {
16167
- if (event.isComposing) return;
16168
16463
  if (this.#pointer?.started) {
16169
16464
  const { pointerType } = this.#pointer;
16170
16465
  this.#teardownSessions();
@@ -16273,6 +16568,7 @@ var PointerDragController = class _PointerDragController extends Controller {
16273
16568
  const interrupted = this.#pointer?.started ? this.#pointer.pointerType : this.#keyboard ? "keyboard" : null;
16274
16569
  this.#teardownSessions();
16275
16570
  for (const handle of this.#handles()) this.#restoreHandle(handle);
16571
+ this.#followReset();
16276
16572
  if (interrupted && this.element.isConnected) this.#dispatchCancel(interrupted);
16277
16573
  }
16278
16574
  /**
@@ -16342,8 +16638,12 @@ var PointerDragController = class _PointerDragController extends Controller {
16342
16638
  }
16343
16639
  /** `touch-action` that lets the page keep panning on the locked axis only. */
16344
16640
  #touchActionForAxis() {
16345
- if (this.axisValue === "x") return "pan-y";
16346
- if (this.axisValue === "y") return "pan-x";
16641
+ return this.#touchActionFor(this.axisValue);
16642
+ }
16643
+ /** The `touch-action` an `axis` declaration lends, for any axis value. */
16644
+ #touchActionFor(axis) {
16645
+ if (axis === "x") return "pan-y";
16646
+ if (axis === "y") return "pan-x";
16347
16647
  return "none";
16348
16648
  }
16349
16649
  /**
@@ -16354,8 +16654,10 @@ var PointerDragController = class _PointerDragController extends Controller {
16354
16654
  * are marker-owned so `#restoreHandle` reverts them symmetrically on teardown.
16355
16655
  */
16356
16656
  #prepareHandle(handle) {
16357
- if (handle.style.touchAction === "" || handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
16358
- handle.style.touchAction = this.#touchActionForAxis();
16657
+ const lent = this.#touchActionForAxis();
16658
+ const marked = handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
16659
+ if (handle.style.touchAction === "" || marked && handle.style.touchAction === lent) {
16660
+ handle.style.touchAction = lent;
16359
16661
  handle.setAttribute(_PointerDragController.#TOUCH_ACTION_MARKER, "true");
16360
16662
  }
16361
16663
  if (handle.tabIndex < 0 && !handle.hasAttribute("tabindex")) {
@@ -16363,17 +16665,27 @@ var PointerDragController = class _PointerDragController extends Controller {
16363
16665
  handle.setAttribute(_PointerDragController.#TABINDEX_MARKER, "true");
16364
16666
  }
16365
16667
  }
16668
+ /** Whether the key belongs to a native control or editing surface in the handle. */
16669
+ #ownsNativeKeys(target, handle) {
16670
+ const owner = target?.closest(NATIVE_KEY_OWNERS) ?? null;
16671
+ return owner !== null && owner !== handle && handle.contains(owner);
16672
+ }
16366
16673
  /** Reverts the marker-owned touch-action + tabindex (authored values untouched). */
16367
16674
  #restoreHandle(handle) {
16368
16675
  if (handle.hasAttribute(_PointerDragController.#TOUCH_ACTION_MARKER)) {
16369
- handle.style.touchAction = "";
16676
+ if (handle.style.touchAction === this.#touchActionForAxis()) {
16677
+ handle.style.touchAction = "";
16678
+ }
16370
16679
  handle.removeAttribute(_PointerDragController.#TOUCH_ACTION_MARKER);
16371
16680
  }
16372
16681
  if (handle.hasAttribute(_PointerDragController.#TABINDEX_MARKER)) {
16373
- handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
16374
- if (handle.getAttribute("tabindex") === "0" && document.activeElement !== handle) {
16682
+ const ours = handle.getAttribute("tabindex") === "0";
16683
+ if (ours && document.activeElement !== handle) {
16375
16684
  handle.removeAttribute("tabindex");
16685
+ handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
16686
+ return;
16376
16687
  }
16688
+ if (!ours) handle.removeAttribute(_PointerDragController.#TABINDEX_MARKER);
16377
16689
  }
16378
16690
  }
16379
16691
  };
@@ -16486,15 +16798,42 @@ var PortalController = class extends Controller {
16486
16798
  static events = ["mount", "unmount"];
16487
16799
  /** Decides whether a `disconnect()` is an in-page move or a real detach. */
16488
16800
  #gate = new DetachGate();
16801
+ /** The `to` declaration after validation; the default when it cannot be parsed. */
16802
+ #toSelector = "body";
16803
+ /**
16804
+ * Validates the destination declaration once, keeping only a selector the engine can
16805
+ * read.
16806
+ *
16807
+ * A selector reads back as an ordinary string, so a malformed one survives until it is
16808
+ * handed to the DOM — where it would take the whole teleport down and leave the part
16809
+ * silently doing nothing. Falling back to the default puts the node somewhere visible
16810
+ * instead, which reads as a mistake and can be traced back to the declaration.
16811
+ */
16812
+ toValueChanged() {
16813
+ const selector = this.toValue;
16814
+ if (selector.length > 0) {
16815
+ try {
16816
+ this.element.matches(selector);
16817
+ this.#toSelector = selector;
16818
+ return;
16819
+ } catch {
16820
+ }
16821
+ }
16822
+ this.#toSelector = "body";
16823
+ }
16489
16824
  connect() {
16490
16825
  this.#gate.cancel();
16491
- if (portalState.has(this.element)) return;
16826
+ const existing = portalState.get(this.element);
16827
+ if (existing) {
16828
+ existing.owner = this;
16829
+ return;
16830
+ }
16492
16831
  const node = this.hasContentTarget ? this.contentTarget : this.element;
16493
16832
  const destination = this.#destination();
16494
16833
  if (!destination || destination === node || node.contains(destination)) return;
16495
16834
  const placeholder = document.createComment("stimeo--portal");
16496
16835
  node.parentNode?.insertBefore(placeholder, node);
16497
- portalState.set(this.element, { node, placeholder });
16836
+ portalState.set(this.element, { node, placeholder, owner: this });
16498
16837
  if (this.positionValue === "prepend") {
16499
16838
  destination.prepend(node);
16500
16839
  } else {
@@ -16513,7 +16852,7 @@ var PortalController = class extends Controller {
16513
16852
  }
16514
16853
  this.#gate.disconnected(this, () => {
16515
16854
  const current = portalState.get(this.element);
16516
- if (current) this.#restore(current);
16855
+ if (current?.owner === this) this.#restore(current);
16517
16856
  });
16518
16857
  }
16519
16858
  /** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */
@@ -16529,30 +16868,84 @@ var PortalController = class extends Controller {
16529
16868
  placeholder.remove();
16530
16869
  this.dispatch("unmount", { detail: {} });
16531
16870
  }
16532
- /** Resolves the destination for `to`, tolerating an invalid selector. */
16871
+ /** Resolves the destination from the validated `to` selector. */
16533
16872
  #destination() {
16534
- const selector = this.toValue.trim();
16535
- if (!selector) return null;
16536
- try {
16537
- return document.querySelector(selector);
16538
- } catch {
16539
- return null;
16873
+ return document.querySelector(this.#toSelector);
16874
+ }
16875
+ };
16876
+
16877
+ // src/utils/style_property_lease.ts
16878
+ var StylePropertyLease = class {
16879
+ #property;
16880
+ #records = /* @__PURE__ */ new Map();
16881
+ /** @param property - The CSS property whose temporary values this lease owns. */
16882
+ constructor(property) {
16883
+ this.#property = property;
16884
+ }
16885
+ /** Writes or removes the leased declaration while preserving its authored value. */
16886
+ write(element, value, priority = "") {
16887
+ const existing = this.#records.get(element);
16888
+ if (existing) {
16889
+ existing.writtenValue = value;
16890
+ existing.writtenPriority = value === null ? "" : priority;
16891
+ } else {
16892
+ this.#records.set(element, {
16893
+ originalValue: element.style.getPropertyValue(this.#property),
16894
+ originalPriority: element.style.getPropertyPriority(this.#property),
16895
+ writtenValue: value,
16896
+ writtenPriority: value === null ? "" : priority
16897
+ });
16898
+ }
16899
+ this.#reflect(element, value, priority);
16900
+ }
16901
+ /** Returns one lease without overwriting a later consumer declaration. */
16902
+ return(element) {
16903
+ const record = this.#records.get(element);
16904
+ if (!record) return;
16905
+ this.#records.delete(element);
16906
+ const style = element.style;
16907
+ const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
16908
+ if (stillOwned) {
16909
+ this.#reflect(element, record.originalValue, record.originalPriority);
16910
+ }
16911
+ }
16912
+ /** Returns every outstanding declaration lease. */
16913
+ returnAll() {
16914
+ for (const element of Array.from(this.#records.keys())) this.return(element);
16915
+ }
16916
+ /** Reflects only a real declaration transition. */
16917
+ #reflect(element, value, priority) {
16918
+ const style = element.style;
16919
+ const nextValue = value ?? "";
16920
+ const nextPriority = value === null ? "" : priority;
16921
+ if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
16922
+ return;
16540
16923
  }
16924
+ if (value === null) style.removeProperty(this.#property);
16925
+ else style.setProperty(this.#property, value, priority);
16541
16926
  }
16542
16927
  };
16928
+
16929
+ // src/controllers/preview_guard_controller.ts
16543
16930
  var PreviewGuardController = class extends Controller {
16544
16931
  static values = {
16545
- placeholder: { type: String, default: "" },
16546
- mode: { type: String, default: "hide" }
16932
+ placeholder: { type: String, default: "" }
16547
16933
  };
16548
16934
  static events = ["hide", "show"];
16935
+ #visibility = new StylePropertyLease("visibility");
16936
+ #beforeCache = new BeforeCacheReset(() => this.#restore());
16549
16937
  #observer = null;
16938
+ #connected = false;
16550
16939
  #hidden = false;
16551
- /** Saved inline visibility (hide mode), restored on show. */
16552
- #savedVisibility = "";
16553
- /** Saved text (placeholder mode); non-null marks that text — not visibility — was swapped. */
16554
- #savedText = null;
16940
+ /** Child nodes a placeholder displaced; non-null marks that content — not visibility — was swapped. */
16941
+ #savedNodes = null;
16942
+ /** The descendant that held focus when the guard went up, so show can hand it back. */
16943
+ #focused = null;
16555
16944
  connect() {
16945
+ this.#connected = true;
16946
+ if (this.#hidden) this.#reguard();
16947
+ else this.element.removeAttribute("data-preview-hidden");
16948
+ this.#beforeCache.activate();
16556
16949
  if (typeof MutationObserver !== "undefined") {
16557
16950
  this.#observer = new MutationObserver(() => this.#sync());
16558
16951
  this.#observer.observe(document.documentElement, {
@@ -16563,44 +16956,115 @@ var PreviewGuardController = class extends Controller {
16563
16956
  this.#sync();
16564
16957
  }
16565
16958
  disconnect() {
16959
+ this.#connected = false;
16960
+ this.#beforeCache.deactivate();
16566
16961
  this.#observer?.disconnect();
16567
16962
  this.#observer = null;
16568
- this.#restore();
16569
16963
  }
16570
- /** Reflects the current `data-turbo-preview` state onto the element. */
16964
+ /**
16965
+ * Re-guards to match a `placeholder` changed at runtime — the value is the content on
16966
+ * display while the guard is up, so a morph that swaps it must not leave the old one.
16967
+ */
16968
+ placeholderValueChanged() {
16969
+ if (!this.#connected || !this.#hidden) return;
16970
+ this.#reguard();
16971
+ }
16972
+ /**
16973
+ * Reflects the current `data-turbo-preview` state onto the element.
16974
+ *
16975
+ * @stimeoRenderRoot
16976
+ */
16571
16977
  #sync() {
16572
16978
  const previewing = document.documentElement.hasAttribute("data-turbo-preview");
16573
16979
  if (previewing && !this.#hidden) this.#hide();
16574
16980
  else if (!previewing && this.#hidden) this.#show();
16575
16981
  }
16576
16982
  #hide() {
16983
+ this.#focused = this.#focusedInside();
16984
+ this.#applyGuard();
16985
+ this.dispatch("hide", { detail: {} });
16986
+ }
16987
+ /** Puts the guard up in the form the current `placeholder` calls for. */
16988
+ #applyGuard() {
16577
16989
  this.#hidden = true;
16578
- if (this.modeValue === "placeholder") {
16579
- this.#savedText = this.element.textContent;
16580
- this.element.textContent = this.placeholderValue;
16990
+ if (this.placeholderValue === "") {
16991
+ this.#visibility.write(this.element, "hidden");
16581
16992
  } else {
16582
- this.#savedVisibility = this.element.style.visibility;
16583
- this.element.style.visibility = "hidden";
16993
+ this.#savedNodes = document.createDocumentFragment();
16994
+ this.#savedNodes.append(...this.element.childNodes);
16995
+ this.element.textContent = this.placeholderValue;
16584
16996
  }
16585
16997
  this.element.setAttribute("data-preview-hidden", "true");
16586
- this.dispatch("hide", { detail: {} });
16998
+ }
16999
+ /**
17000
+ * Re-forms a guard that is already up so it matches the current `placeholder`.
17001
+ *
17002
+ * No event: `hide` reports that the guard went up, and it has not come down. Swapping
17003
+ * one stand-in text for another writes only that text — putting the held content back
17004
+ * first would reconnect the whole subtree for an instant. Only a change of *form* —
17005
+ * to or from the empty placeholder — has to revert, and the focus the guard is holding
17006
+ * carries across it rather than being handed back and taken again.
17007
+ */
17008
+ #reguard() {
17009
+ if (this.#savedNodes && this.placeholderValue !== "") {
17010
+ this.element.textContent = this.placeholderValue;
17011
+ return;
17012
+ }
17013
+ const held = this.#focused;
17014
+ this.#revert();
17015
+ this.#applyGuard();
17016
+ this.#focused = held;
16587
17017
  }
16588
17018
  #show() {
16589
17019
  this.#restore();
16590
17020
  this.dispatch("show", { detail: {} });
16591
17021
  }
16592
- /** Reverts the guard. Safe to call when not hidden (no-op) — used by show and teardown. */
17022
+ /** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */
16593
17023
  #restore() {
16594
- if (!this.#hidden) return;
17024
+ this.#revert();
17025
+ this.#refocus();
17026
+ }
17027
+ /**
17028
+ * Puts the element back the way the guard found it.
17029
+ *
17030
+ * Every step is a no-op on an element this controller never guarded: the lease returns
17031
+ * only declarations it recorded, and the hook is removed whether or not it is there.
17032
+ */
17033
+ #revert() {
16595
17034
  this.#hidden = false;
16596
- if (this.#savedText !== null) {
16597
- this.element.textContent = this.#savedText;
16598
- this.#savedText = null;
17035
+ if (this.#savedNodes) {
17036
+ this.element.textContent = "";
17037
+ this.element.append(this.#savedNodes);
17038
+ this.#savedNodes = null;
16599
17039
  } else {
16600
- this.element.style.visibility = this.#savedVisibility;
17040
+ this.#visibility.return(this.element);
16601
17041
  }
16602
17042
  this.element.removeAttribute("data-preview-hidden");
16603
17043
  }
17044
+ /**
17045
+ * The focused element the guard is about to make unfocusable, if any.
17046
+ *
17047
+ * The element itself counts as well as its descendants: guarding takes focus either way
17048
+ * — a `visibility: hidden` subtree cannot hold it, and a placeholder displaces the nodes
17049
+ * outright — so the browser drops focus to `<body>` the moment the guard goes up.
17050
+ */
17051
+ #focusedInside() {
17052
+ const active = document.activeElement;
17053
+ return active instanceof HTMLElement && this.element.contains(active) ? active : null;
17054
+ }
17055
+ /**
17056
+ * Hands focus back to the element the guard took it from.
17057
+ *
17058
+ * Only when that element is still in the document and focus has not moved on since —
17059
+ * anything else is the user's or another controller's, and putting it back would be
17060
+ * taking it.
17061
+ */
17062
+ #refocus() {
17063
+ const target = this.#focused;
17064
+ this.#focused = null;
17065
+ if (!target?.isConnected || document.activeElement !== document.body) return;
17066
+ target.focus();
17067
+ }
16604
17068
  };
16605
17069
  var OWNED_VALUE_TEXT2 = "data-stimeo--progress-owns-valuetext";
16606
17070
  var ProgressController = class extends Controller {
@@ -18326,7 +18790,7 @@ var ResizableController = class extends Controller {
18326
18790
  this.#dispatchChange();
18327
18791
  }
18328
18792
  }
18329
- /** Double-click or Enter to collapse the primary pane, or put it back. */
18793
+ /** Collapses the primary pane to its minimum, or returns it to the last position. */
18330
18794
  toggle() {
18331
18795
  const { min, max } = this.#range;
18332
18796
  if (this.#position > min) {
@@ -18402,6 +18866,7 @@ var ResizableController = class extends Controller {
18402
18866
  return this.hasSeparatorTarget && this.separatorTarget.getAttribute("aria-orientation") === "vertical";
18403
18867
  }
18404
18868
  };
18869
+ var STATE_ATTRIBUTES2 = ["disabled", "hidden"];
18405
18870
  var RovingController = class extends Controller {
18406
18871
  static targets = ["item"];
18407
18872
  static values = {
@@ -18411,12 +18876,14 @@ var RovingController = class extends Controller {
18411
18876
  };
18412
18877
  static events = ["change"];
18413
18878
  #roving = new RovingTabindex(() => this.itemTargets);
18414
- #reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop());
18879
+ #reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop(true));
18415
18880
  #connected = false;
18881
+ #observer = null;
18416
18882
  connect() {
18417
- this.#ensureTabStop();
18883
+ this.#ensureTabStop(false);
18418
18884
  this.element.addEventListener("keydown", this.#onKeydown);
18419
18885
  this.element.addEventListener("focusin", this.#onFocusin);
18886
+ this.#watchState();
18420
18887
  this.#connected = true;
18421
18888
  this.#reconcile.activate();
18422
18889
  }
@@ -18425,6 +18892,8 @@ var RovingController = class extends Controller {
18425
18892
  this.#reconcile.cancel();
18426
18893
  this.element.removeEventListener("keydown", this.#onKeydown);
18427
18894
  this.element.removeEventListener("focusin", this.#onFocusin);
18895
+ this.#observer?.disconnect();
18896
+ this.#observer = null;
18428
18897
  }
18429
18898
  /** Drops a runtime-added item from the Tab sequence before batch reconciliation. */
18430
18899
  itemTargetConnected(item) {
@@ -18440,10 +18909,9 @@ var RovingController = class extends Controller {
18440
18909
  #onKeydown = (event) => {
18441
18910
  if (event.defaultPrevented) return;
18442
18911
  if (isReservedArrowChord(event)) return;
18443
- const items = this.itemTargets;
18912
+ if (event.isComposing) return;
18444
18913
  const current = this.#indexOf(event.target);
18445
18914
  if (current === -1) return;
18446
- const length = items.length;
18447
18915
  const wrap = this.wrapValue ? "wrap" : "clamp";
18448
18916
  const orientation = this.orientationValue;
18449
18917
  const horizontal = orientation === "horizontal" || orientation === "both";
@@ -18453,16 +18921,16 @@ var RovingController = class extends Controller {
18453
18921
  const backwardKey = rtl ? "ArrowRight" : "ArrowLeft";
18454
18922
  let next;
18455
18923
  if (horizontal && event.key === forwardKey || vertical && event.key === "ArrowDown") {
18456
- next = rovingMove(current, length, 1, wrap);
18924
+ next = this.#step(current, 1, wrap);
18457
18925
  } else if (horizontal && event.key === backwardKey || vertical && event.key === "ArrowUp") {
18458
- next = rovingMove(current, length, -1, wrap);
18459
- } else if (this.homeEndValue && event.key === "Home") {
18460
- next = 0;
18461
- } else if (this.homeEndValue && event.key === "End") {
18462
- next = length - 1;
18926
+ next = this.#step(current, -1, wrap);
18927
+ } else if (this.homeEndValue && (event.key === "Home" || event.key === "End")) {
18928
+ if (hasModifierChord(event)) return;
18929
+ next = event.key === "Home" ? this.#firstReachable() : this.#lastReachable();
18463
18930
  } else {
18464
18931
  return;
18465
18932
  }
18933
+ if (next === -1) return;
18466
18934
  event.preventDefault();
18467
18935
  this.#activate(next, true);
18468
18936
  };
@@ -18473,7 +18941,8 @@ var RovingController = class extends Controller {
18473
18941
  */
18474
18942
  #onFocusin = (event) => {
18475
18943
  const index = this.#indexOf(event.target);
18476
- if (index !== -1) this.#activate(index, false);
18944
+ if (index === -1 || !this.#reachable(index)) return;
18945
+ this.#activate(index, false);
18477
18946
  };
18478
18947
  /** Resolves the item index owning an event target (the item or a descendant). */
18479
18948
  #indexOf(target) {
@@ -18489,66 +18958,106 @@ var RovingController = class extends Controller {
18489
18958
  this.dispatch("change", { detail: { index, item: this.itemTargets[index] } });
18490
18959
  }
18491
18960
  }
18492
- /** Keeps the first existing Tab stop, falling back to the first live item. */
18493
- #ensureTabStop() {
18961
+ /**
18962
+ * Keeps the Tab stop on a reachable item.
18963
+ *
18964
+ * `followFocus` is on for re-establishment only: an item added and focused in
18965
+ * the same task has already claimed the stop through `focusin`, and the batch
18966
+ * that follows must not hand it back to the first item. `connect()` passes it
18967
+ * off so the authored DOM decides the initial stop. With nothing reachable the
18968
+ * DOM is left as it is — a group inside a collapsed region gets its stop back
18969
+ * when the region opens, instead of losing it for good.
18970
+ */
18971
+ #ensureTabStop(followFocus) {
18972
+ const items = this.itemTargets;
18973
+ const focused = followFocus ? items.findIndex((item, i) => item === document.activeElement && this.#reachable(i)) : -1;
18494
18974
  const active = this.#roving.activeIndex;
18495
- this.#roving.setActive(active === -1 ? 0 : active);
18496
- }
18497
- };
18498
-
18499
- // src/utils/style_property_lease.ts
18500
- var StylePropertyLease = class {
18501
- #property;
18502
- #records = /* @__PURE__ */ new Map();
18503
- /** @param property - The CSS property whose temporary values this lease owns. */
18504
- constructor(property) {
18505
- this.#property = property;
18975
+ const kept = active !== -1 && this.#reachable(active) ? active : -1;
18976
+ const index = focused !== -1 ? focused : kept !== -1 ? kept : this.#firstReachable();
18977
+ if (index === -1) return;
18978
+ this.#roving.setActive(index);
18506
18979
  }
18507
- /** Writes or removes the leased declaration while preserving its authored value. */
18508
- write(element, value, priority = "") {
18509
- const existing = this.#records.get(element);
18510
- if (existing) {
18511
- existing.writtenValue = value;
18512
- existing.writtenPriority = value === null ? "" : priority;
18513
- } else {
18514
- this.#records.set(element, {
18515
- originalValue: element.style.getPropertyValue(this.#property),
18516
- originalPriority: element.style.getPropertyPriority(this.#property),
18517
- writtenValue: value,
18518
- writtenPriority: value === null ? "" : priority
18519
- });
18980
+ /**
18981
+ * Resolves the next reachable index in `delta`'s direction, honouring `wrap`.
18982
+ *
18983
+ * An arrow on the widget's own axis is the widget's to consume even when the
18984
+ * position does not change, so a clamped end resolves to the current item
18985
+ * rather than to nothing. An unreachable origin escapes to the first reachable
18986
+ * item instead of sitting in a dead end, and `-1` is left for the one case that
18987
+ * really is not ours: no reachable item anywhere.
18988
+ */
18989
+ #step(current, delta, wrap) {
18990
+ const length = this.itemTargets.length;
18991
+ let index = current;
18992
+ for (let taken = 0; taken < length; taken += 1) {
18993
+ const candidate = rovingMove(index, length, delta, wrap);
18994
+ if (candidate === index) break;
18995
+ index = candidate;
18996
+ if (this.#reachable(index)) return index;
18997
+ }
18998
+ return this.#reachable(current) ? current : this.#firstReachable();
18999
+ }
19000
+ /** Index of the first reachable item, or `-1`. */
19001
+ #firstReachable() {
19002
+ return this.itemTargets.findIndex((_item, i) => this.#reachable(i));
19003
+ }
19004
+ /** Index of the last reachable item, or `-1`. */
19005
+ #lastReachable() {
19006
+ for (let i = this.itemTargets.length - 1; i >= 0; i -= 1) {
19007
+ if (this.#reachable(i)) return i;
18520
19008
  }
18521
- this.#reflect(element, value, priority);
19009
+ return -1;
18522
19010
  }
18523
- /** Returns one lease without overwriting a later consumer declaration. */
18524
- return(element) {
18525
- const record = this.#records.get(element);
18526
- if (!record) return;
18527
- this.#records.delete(element);
18528
- const style = element.style;
18529
- const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
18530
- if (stillOwned) {
18531
- this.#reflect(element, record.originalValue, record.originalPriority);
18532
- }
19011
+ /**
19012
+ * Whether the item at `index` can hold the Tab stop and take focus.
19013
+ *
19014
+ * `aria-disabled` is deliberately not consulted: it keeps an item reachable and
19015
+ * only suppresses activation, which this part does not own.
19016
+ */
19017
+ #reachable(index) {
19018
+ const item = this.itemTargets[index];
19019
+ if (!item) return false;
19020
+ if (this.#hidden(item)) return false;
19021
+ if (!("disabled" in item)) return true;
19022
+ if (item.disabled) return false;
19023
+ return !inheritsFieldsetDisabled(item);
18533
19024
  }
18534
- /** Returns every outstanding declaration lease. */
18535
- returnAll() {
18536
- for (const element of Array.from(this.#records.keys())) this.return(element);
19025
+ /**
19026
+ * Whether `item`, or anything between it and the container, is `hidden`.
19027
+ *
19028
+ * The walk stops at the container on purpose: a group inside a hidden region is
19029
+ * already out of the page's Tab order, and calling every item unreachable there
19030
+ * would drop the stop with nothing left to restore it — the ancestor lies
19031
+ * outside the subtree whose state attributes are watched.
19032
+ */
19033
+ #hidden(item) {
19034
+ let node = item;
19035
+ while (node && node !== this.element) {
19036
+ if (node.hasAttribute("hidden")) return true;
19037
+ node = node.parentElement;
19038
+ }
19039
+ return false;
18537
19040
  }
18538
- /** Reflects only a real declaration transition. */
18539
- #reflect(element, value, priority) {
18540
- const style = element.style;
18541
- const nextValue = value ?? "";
18542
- const nextPriority = value === null ? "" : priority;
18543
- if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
18544
- return;
19041
+ /**
19042
+ * Watches the attributes that decide reachability, so disabling the item that
19043
+ * holds the Tab stop hands it to another one instead of taking the whole set
19044
+ * out of the Tab sequence. An enclosing `fieldset` disables items from outside
19045
+ * the observed subtree, so each one's own `disabled` is watched too.
19046
+ */
19047
+ #watchState() {
19048
+ if (typeof MutationObserver === "undefined") return;
19049
+ const observer = new MutationObserver(() => this.#reconcile.schedule());
19050
+ observer.observe(this.element, {
19051
+ subtree: true,
19052
+ attributes: true,
19053
+ attributeFilter: STATE_ATTRIBUTES2
19054
+ });
19055
+ for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
19056
+ observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });
18545
19057
  }
18546
- if (value === null) style.removeProperty(this.#property);
18547
- else style.setProperty(this.#property, value, priority);
19058
+ this.#observer = observer;
18548
19059
  }
18549
19060
  };
18550
-
18551
- // src/controllers/scroll_area_controller.ts
18552
19061
  var EDGE_EPSILON = 1;
18553
19062
  var ScrollAreaController = class extends Controller {
18554
19063
  static targets = ["viewport"];
@@ -18908,6 +19417,17 @@ var ScrollAreaController = class extends Controller {
18908
19417
  this.#clearHostState();
18909
19418
  }
18910
19419
  };
19420
+ function parseStored(raw) {
19421
+ if (raw === null) return null;
19422
+ try {
19423
+ return JSON.parse(raw);
19424
+ } catch {
19425
+ return null;
19426
+ }
19427
+ }
19428
+ function storedOffset(value) {
19429
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
19430
+ }
18911
19431
  var ScrollRestoreController = class extends Controller {
18912
19432
  static values = {
18913
19433
  key: { type: String, default: "" },
@@ -18920,6 +19440,26 @@ var ScrollRestoreController = class extends Controller {
18920
19440
  /** Last offset captured while the element was live; persisted as-is on teardown. */
18921
19441
  #lastTop = 0;
18922
19442
  #lastLeft = 0;
19443
+ /**
19444
+ * The offsets this controller's own restore produced, awaiting the `scroll`
19445
+ * the engine fires for them. A restore the layout cannot reach yet lands short,
19446
+ * so treating that echo as the reader's position would cut the saved offset
19447
+ * down to whatever the unfinished layout allowed.
19448
+ */
19449
+ #echoTop = null;
19450
+ #echoLeft = null;
19451
+ /** Offsets stored for an axis that is not tracked, carried through saves. */
19452
+ #carried = {};
19453
+ /**
19454
+ * Whether each axis holds an offset worth saving — one this namespace restored
19455
+ * or the reader moved. A save writes only the axes that are marked, so an
19456
+ * offset taken under a different key or axis is never re-published as if the
19457
+ * reader had left it there.
19458
+ */
19459
+ #capturedTop = false;
19460
+ #capturedLeft = false;
19461
+ /** Whether the controller is connected; Value callbacks outside that window do not resync. */
19462
+ #connected = false;
18923
19463
  #onScroll = () => {
18924
19464
  this.#capture();
18925
19465
  if (this.#rafId !== null) return;
@@ -18929,58 +19469,127 @@ var ScrollRestoreController = class extends Controller {
18929
19469
  });
18930
19470
  };
18931
19471
  connect() {
19472
+ this.#connected = true;
18932
19473
  this.#storageKey = this.#resolveKey();
18933
- if (!this.#storageKey) return;
18934
- this.#restore();
18935
19474
  this.element.addEventListener("scroll", this.#onScroll, { passive: true });
19475
+ if (this.#storageKey) this.#restore();
18936
19476
  }
18937
19477
  disconnect() {
18938
- if (!this.#storageKey) return;
19478
+ this.#connected = false;
18939
19479
  this.element.removeEventListener("scroll", this.#onScroll);
19480
+ this.#cancelFrame();
19481
+ this.#persist();
19482
+ }
19483
+ /** Re-derives the namespace when application code or a Turbo morph moves `key`. */
19484
+ keyValueChanged() {
19485
+ this.#resync();
19486
+ }
19487
+ /** Re-derives the tracked axes when application code or a Turbo morph moves `axis`. */
19488
+ axisValueChanged() {
19489
+ this.#resync();
19490
+ }
19491
+ /** Rebuilds the persistence state around the Values as they now read. */
19492
+ #resync() {
19493
+ if (!this.#connected) return;
19494
+ this.#cancelFrame();
19495
+ this.#storageKey = this.#resolveKey();
19496
+ this.#echoTop = null;
19497
+ this.#echoLeft = null;
19498
+ this.#carried = {};
19499
+ this.#capturedTop = false;
19500
+ this.#capturedLeft = false;
19501
+ if (this.#storageKey) this.#restore();
19502
+ }
19503
+ /** Drops the pending coalesced save, if one is queued. */
19504
+ #cancelFrame() {
18940
19505
  if (this.#rafId !== null) {
18941
19506
  cancelAnimationFrame(this.#rafId);
18942
19507
  this.#rafId = null;
18943
19508
  }
18944
- this.#persist();
18945
19509
  }
18946
- /** Records the live scroll offset for the configured axis. */
19510
+ /**
19511
+ * Records the live scroll offset for the configured axis.
19512
+ *
19513
+ * An axis holds its echo until that axis actually moves. A scroll event names
19514
+ * no axis, so consuming both on the first one to arrive would leave the other
19515
+ * unguarded: on `both`, moving one axis would take the clamped reading of the
19516
+ * untouched axis as the reader's own position and save it, cutting the stored
19517
+ * offset down to whatever the layout could reach at restore time.
19518
+ */
18947
19519
  #capture() {
18948
- if (this.#tracksVertical) this.#lastTop = this.element.scrollTop;
18949
- if (this.#tracksHorizontal) this.#lastLeft = this.element.scrollLeft;
19520
+ if (this.#tracksVertical) {
19521
+ const top = this.element.scrollTop;
19522
+ if (top !== this.#echoTop) {
19523
+ this.#echoTop = null;
19524
+ this.#lastTop = top;
19525
+ this.#capturedTop = true;
19526
+ }
19527
+ }
19528
+ if (this.#tracksHorizontal) {
19529
+ const left = this.element.scrollLeft;
19530
+ if (left !== this.#echoLeft) {
19531
+ this.#echoLeft = null;
19532
+ this.#lastLeft = left;
19533
+ this.#capturedLeft = true;
19534
+ }
19535
+ }
18950
19536
  }
18951
- /** Persists the last captured scroll offset for the configured axis. */
19537
+ /**
19538
+ * Writes the marked axes, carrying through any offset held for an untracked one.
19539
+ * Without a key, or with neither axis marked, it writes nothing.
19540
+ */
18952
19541
  #persist() {
18953
- const data = {};
18954
- if (this.#tracksVertical) data.top = this.#lastTop;
18955
- if (this.#tracksHorizontal) data.left = this.#lastLeft;
19542
+ if (!this.#storageKey || !(this.#capturedTop || this.#capturedLeft)) return;
19543
+ const data = { ...this.#carried };
19544
+ if (this.#tracksVertical && this.#capturedTop) data.top = this.#lastTop;
19545
+ if (this.#tracksHorizontal && this.#capturedLeft) data.left = this.#lastLeft;
18956
19546
  try {
18957
- sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
19547
+ window.sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
18958
19548
  } catch {
18959
19549
  }
18960
19550
  }
18961
19551
  /** Applies the persisted scroll offset, if any, without moving focus. */
18962
19552
  #restore() {
18963
- let raw = null;
18964
- try {
18965
- raw = sessionStorage.getItem(this.#storageKey);
18966
- } catch {
18967
- return;
18968
- }
18969
- if (raw === null) return;
18970
- let data;
19553
+ let raw;
18971
19554
  try {
18972
- data = JSON.parse(raw);
19555
+ raw = window.sessionStorage.getItem(this.#storageKey);
18973
19556
  } catch {
18974
19557
  return;
18975
19558
  }
18976
- if (this.#tracksVertical && typeof data.top === "number") {
18977
- this.element.scrollTop = data.top;
18978
- this.#lastTop = data.top;
18979
- }
18980
- if (this.#tracksHorizontal && typeof data.left === "number") {
18981
- this.element.scrollLeft = data.left;
18982
- this.#lastLeft = data.left;
19559
+ const data = parseStored(raw);
19560
+ if (data === null) return;
19561
+ this.#echoTop = null;
19562
+ this.#echoLeft = null;
19563
+ const top = storedOffset(data.top);
19564
+ const left = storedOffset(data.left);
19565
+ const options = { behavior: "instant" };
19566
+ let requested = false;
19567
+ if (this.#tracksVertical) {
19568
+ if (top !== null) {
19569
+ options.top = top;
19570
+ this.#lastTop = top;
19571
+ this.#capturedTop = true;
19572
+ requested = true;
19573
+ }
19574
+ } else if (data.top !== void 0) {
19575
+ this.#carried.top = data.top;
19576
+ }
19577
+ if (this.#tracksHorizontal) {
19578
+ if (left !== null) {
19579
+ options.left = left;
19580
+ this.#lastLeft = left;
19581
+ this.#capturedLeft = true;
19582
+ requested = true;
19583
+ }
19584
+ } else if (data.left !== void 0) {
19585
+ this.#carried.left = data.left;
18983
19586
  }
19587
+ if (!requested) return;
19588
+ const beforeTop = this.element.scrollTop;
19589
+ const beforeLeft = this.element.scrollLeft;
19590
+ this.element.scrollTo(options);
19591
+ if (this.element.scrollTop !== beforeTop) this.#echoTop = this.element.scrollTop;
19592
+ if (this.element.scrollLeft !== beforeLeft) this.#echoLeft = this.element.scrollLeft;
18984
19593
  }
18985
19594
  /** The `sessionStorage` key: explicit `key`, else the element `id`, else none. */
18986
19595
  #resolveKey() {
@@ -18994,10 +19603,11 @@ var ScrollRestoreController = class extends Controller {
18994
19603
  return this.axisValue === "horizontal" || this.axisValue === "both";
18995
19604
  }
18996
19605
  };
19606
+ var DEFAULT_OFFSET = 400;
18997
19607
  var ScrollVisibilityController = class extends Controller {
18998
19608
  static targets = ["element"];
18999
19609
  static values = {
19000
- offset: { type: Number, default: 400 },
19610
+ offset: { type: Number, default: DEFAULT_OFFSET },
19001
19611
  mode: { type: String, default: "offset" },
19002
19612
  focusSelector: { type: String, default: "" },
19003
19613
  root: { type: String, default: "" }
@@ -19015,8 +19625,32 @@ var ScrollVisibilityController = class extends Controller {
19015
19625
  * the window. Captured on connect so teardown detaches from the same source.
19016
19626
  */
19017
19627
  #scrollSource = window;
19628
+ /**
19629
+ * Gates the declaration callbacks to the connected window.
19630
+ *
19631
+ * Stimulus delivers a Value callback ahead of `connect()` and again for every
19632
+ * runtime change; without the gate, merely connecting would evaluate — and
19633
+ * announce — before `connect()` runs its own first reflection.
19634
+ */
19635
+ #connected = false;
19636
+ /** Validated threshold; a non-finite declaration reads as the default. */
19637
+ #offset = DEFAULT_OFFSET;
19638
+ /** Validated `root` selector; an unparsable declaration reads as absent. */
19639
+ #rootSelector = "";
19640
+ /** Validated `focusSelector`; an unparsable declaration reads as absent. */
19641
+ #focusSelector = "";
19018
19642
  /** Focus targets this instance lent a `tabindex` to. */
19019
19643
  #tabindex = new TabindexLoan();
19644
+ /**
19645
+ * The hide held back while the control itself owns focus.
19646
+ *
19647
+ * Completing the deferral re-runs the ordinary evaluation rather than applying
19648
+ * the stale decision: by the time focus leaves, the scroll position may have
19649
+ * moved back past the threshold.
19650
+ */
19651
+ #pendingHide = new BlurDeferral(() => {
19652
+ if (this.#connected) this.#evaluate();
19653
+ });
19020
19654
  #onScroll = () => {
19021
19655
  if (this.#rafId !== null) return;
19022
19656
  this.#rafId = requestAnimationFrame(() => {
@@ -19028,61 +19662,134 @@ var ScrollVisibilityController = class extends Controller {
19028
19662
  this.#scrollSource = this.#resolveScrollSource();
19029
19663
  this.#lastScrollY = this.#scrollY();
19030
19664
  this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
19031
- this.#evaluate();
19665
+ this.#evaluate(false);
19666
+ this.#connected = true;
19032
19667
  }
19033
19668
  disconnect() {
19669
+ this.#connected = false;
19034
19670
  this.#scrollSource.removeEventListener("scroll", this.#onScroll);
19035
19671
  if (this.#rafId !== null) {
19036
19672
  cancelAnimationFrame(this.#rafId);
19037
19673
  this.#rafId = null;
19038
19674
  }
19675
+ this.#pendingHide.releaseAll();
19039
19676
  this.#tabindex.returnAll();
19040
19677
  this.#visible = null;
19041
19678
  }
19679
+ /** Writes the current visibility onto a control that arrives after connect. */
19680
+ elementTargetConnected(element) {
19681
+ if (this.#visible !== null) element.hidden = !this.#visible;
19682
+ }
19683
+ /** Drops a held-back hide together with the control it was waiting on. */
19684
+ elementTargetDisconnected() {
19685
+ this.#pendingHide.releaseAll();
19686
+ }
19687
+ /**
19688
+ * Validates `offset` once, then re-renders.
19689
+ *
19690
+ * Re-renders when application code (or a Turbo morph) changes `offset` at
19691
+ * runtime. A declaration that is not a finite number reads as the default, so
19692
+ * the comparison path never sees `NaN` — which would answer `false` to every
19693
+ * comparison and strand the element (in `direction` mode, even the guarantee
19694
+ * that the very top always reveals).
19695
+ */
19696
+ offsetValueChanged() {
19697
+ this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
19698
+ if (this.#connected) this.#evaluate();
19699
+ }
19700
+ /** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */
19701
+ modeValueChanged() {
19702
+ if (this.#connected) this.#evaluate();
19703
+ }
19704
+ /** Validates `root` once so connect never parses a selector that throws. */
19705
+ rootValueChanged() {
19706
+ this.#rootSelector = this.#validSelector(this.rootValue);
19707
+ }
19708
+ /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
19709
+ focusSelectorValueChanged() {
19710
+ this.#focusSelector = this.#validSelector(this.focusSelectorValue);
19711
+ }
19042
19712
  /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
19043
19713
  toTop() {
19044
19714
  const behavior = prefersReducedMotion() ? "instant" : "smooth";
19045
19715
  this.#scrollSource.scrollTo({ top: 0, behavior });
19046
- if (this.focusSelectorValue) {
19047
- const target = document.querySelector(this.focusSelectorValue);
19716
+ if (this.#focusSelector) {
19717
+ const target = document.querySelector(this.#focusSelector);
19048
19718
  if (target) {
19049
19719
  this.#tabindex.lend(target);
19050
- target.focus();
19720
+ target.focus({ preventScroll: true });
19051
19721
  }
19052
19722
  }
19053
19723
  }
19054
- /** Decides the next visibility from the current scroll state and applies it. */
19055
- #evaluate() {
19724
+ /**
19725
+ * Decides the next visibility from the current scroll state and applies it.
19726
+ *
19727
+ * @param notify - whether a transition announces itself. The reflection
19728
+ * `connect()` performs is the current state, not a change.
19729
+ *
19730
+ * @stimeoRenderRoot
19731
+ */
19732
+ #evaluate(notify = true) {
19056
19733
  const y = this.#scrollY();
19057
19734
  let nextVisible;
19058
19735
  if (this.modeValue === "direction") {
19059
- if (y <= this.offsetValue) {
19736
+ if (y <= this.#offset) {
19060
19737
  nextVisible = true;
19738
+ } else if (y === this.#lastScrollY) {
19739
+ return;
19061
19740
  } else {
19062
19741
  nextVisible = y < this.#lastScrollY;
19063
19742
  }
19064
19743
  } else {
19065
- nextVisible = y > this.offsetValue;
19744
+ nextVisible = y > this.#offset;
19066
19745
  }
19067
19746
  this.#lastScrollY = y;
19068
- this.#setVisible(nextVisible);
19747
+ this.#setVisible(nextVisible, notify);
19069
19748
  }
19070
19749
  /** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */
19071
- #setVisible(next) {
19750
+ #setVisible(next, notify) {
19072
19751
  if (next === this.#visible) return;
19752
+ const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;
19753
+ if (focused) {
19754
+ this.#pendingHide.deferOnly(focused);
19755
+ return;
19756
+ }
19073
19757
  this.#visible = next;
19074
19758
  if (this.hasElementTarget) this.elementTarget.hidden = !next;
19075
19759
  this.element.setAttribute("data-state", next ? "visible" : "hidden");
19076
- this.dispatch("change", { detail: { visible: next } });
19760
+ if (notify) this.dispatch("change", { detail: { visible: next } });
19077
19761
  }
19078
19762
  /** Resolves the scroll source from `root` (falling back to the window). */
19079
19763
  #resolveScrollSource() {
19080
- if (this.rootValue) {
19081
- const root = document.querySelector(this.rootValue);
19764
+ if (this.#rootSelector) {
19765
+ const root = document.querySelector(this.#rootSelector);
19082
19766
  if (root) return root;
19083
19767
  }
19084
19768
  return window;
19085
19769
  }
19770
+ /**
19771
+ * The focus owner inside the target, or `null` when focus is elsewhere.
19772
+ *
19773
+ * `blur` does not bubble, so the deferral has to ride the focused element
19774
+ * itself: waiting on a container that never receives the event would hold the
19775
+ * hide forever.
19776
+ */
19777
+ #focusedWithin() {
19778
+ const focused = document.activeElement;
19779
+ if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
19780
+ return null;
19781
+ }
19782
+ /** Returns `declared` when it parses as a selector, and `""` when it does not. */
19783
+ #validSelector(declared) {
19784
+ if (declared.length > 0) {
19785
+ try {
19786
+ this.element.matches(declared);
19787
+ return declared;
19788
+ } catch {
19789
+ }
19790
+ }
19791
+ return "";
19792
+ }
19086
19793
  #scrollY() {
19087
19794
  if (this.#scrollSource === window) {
19088
19795
  return window.scrollY ?? window.pageYOffset ?? 0;
@@ -20650,11 +21357,6 @@ var StepIndicatorController = class extends Controller {
20650
21357
  };
20651
21358
  static actions = ["setCurrent"];
20652
21359
  static events = ["change"];
20653
- /**
20654
- * Whether the target callbacks may render. Stimulus reports the authored steps
20655
- * as connected before `connect()` and the remaining ones as disconnected after
20656
- * `disconnect()`, so this keeps a connect at one render pass, not one per step.
20657
- */
20658
21360
  /**
20659
21361
  * Collapses a batch of step callbacks — and a morph that swaps `current` with
20660
21362
  * them — into one repaint. Replacing a list of N steps delivers N callbacks, and
@@ -20824,6 +21526,7 @@ var StepperController = class extends Controller {
20824
21526
  return Math.min(last, Math.max(0, Math.trunc(index)));
20825
21527
  }
20826
21528
  };
21529
+ var DEFAULT_THRESHOLD = 80;
20827
21530
  var countElements = (nodes) => {
20828
21531
  let n = 0;
20829
21532
  for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
@@ -20832,34 +21535,53 @@ var countElements = (nodes) => {
20832
21535
  var StickToBottomController = class extends Controller {
20833
21536
  static targets = ["content"];
20834
21537
  static values = {
20835
- threshold: { type: Number, default: 80 },
21538
+ threshold: { type: Number, default: DEFAULT_THRESHOLD },
20836
21539
  behavior: { type: String, default: "auto" },
20837
21540
  pinOnConnect: { type: Boolean, default: false }
20838
21541
  };
20839
21542
  static actions = ["scrollToBottom"];
20840
21543
  static events = ["pin", "new"];
20841
21544
  #observer = null;
20842
- /** Watches for the box a deferred `pinOnConnect` jump is still waiting on. */
20843
- #layout = null;
21545
+ /** The element the append observer currently holds, so a swap can be detected. */
21546
+ #watched = null;
21547
+ /** Watches for the box a container connected without one is still waiting on. */
21548
+ #layout = new LayoutObserver(() => this.#onLaidOut());
21549
+ #awaitingLayout = false;
21550
+ #connected = false;
20844
21551
  #pinned = false;
20845
21552
  #onScroll = () => this.#updatePinned();
20846
21553
  connect() {
21554
+ this.#connected = true;
20847
21555
  if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
21556
+ this.element.removeAttribute("data-has-new");
20848
21557
  this.#pinned = this.#isPinned();
20849
21558
  this.#reflectPinned();
20850
21559
  this.element.addEventListener("scroll", this.#onScroll, { passive: true });
20851
- if (typeof MutationObserver !== "undefined") {
20852
- this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
20853
- this.#observer.observe(this.#watched(), { childList: true });
20854
- }
20855
- if (this.pinOnConnectValue && !this.#measurable()) this.#pinWhenLaidOut();
21560
+ this.#syncWatched();
21561
+ if (!this.#measurable()) this.#waitForLayout();
20856
21562
  }
20857
21563
  disconnect() {
21564
+ this.#connected = false;
20858
21565
  this.element.removeEventListener("scroll", this.#onScroll);
20859
- this.#observer?.disconnect();
20860
- this.#observer = null;
21566
+ this.#stopWatching();
20861
21567
  this.#stopWaitingForLayout();
20862
21568
  }
21569
+ /** Moves the append watch onto a `content` target that arrived at runtime. */
21570
+ contentTargetConnected() {
21571
+ this.#syncWatched();
21572
+ }
21573
+ /** Moves the append watch off a `content` target that left, back onto the container. */
21574
+ contentTargetDisconnected() {
21575
+ this.#syncWatched();
21576
+ }
21577
+ /**
21578
+ * Re-derives pinned when the distance that counts as the bottom is changed at runtime
21579
+ * (a morph that swaps the attribute on a retained element).
21580
+ */
21581
+ thresholdValueChanged() {
21582
+ if (!this.#connected) return;
21583
+ this.#updatePinned();
21584
+ }
20863
21585
  /**
20864
21586
  * Jumps to the bottom and re-pins (wired to a "new messages" button).
20865
21587
  *
@@ -20888,7 +21610,11 @@ var StickToBottomController = class extends Controller {
20888
21610
  this.dispatch("new", { detail: { count: added } });
20889
21611
  }
20890
21612
  }
20891
- /** Recomputes pinned from the scroll position and reflects it on a transition. */
21613
+ /**
21614
+ * Recomputes pinned from the scroll position and reflects it on a transition.
21615
+ *
21616
+ * @stimeoRenderRoot
21617
+ */
20892
21618
  #updatePinned() {
20893
21619
  const pinned = this.#isPinned();
20894
21620
  if (pinned === this.#pinned) return;
@@ -20905,9 +21631,25 @@ var StickToBottomController = class extends Controller {
20905
21631
  this.element.removeAttribute("data-pinned");
20906
21632
  }
20907
21633
  }
21634
+ /** Whether the container currently sits within `threshold` of its bottom. */
20908
21635
  #isPinned() {
21636
+ if (!this.#measurable()) return false;
20909
21637
  const el = this.element;
20910
- return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;
21638
+ return el.scrollHeight - el.clientHeight - el.scrollTop <= this.#threshold;
21639
+ }
21640
+ /**
21641
+ * The distance from the bottom that counts as pinned: a finite, non-negative number of
21642
+ * pixels. Anything else names no distance the container can be at, and settles the
21643
+ * comparison the same way at every scroll position, so it falls back to the default.
21644
+ * `Number` reads `"abc"` as `NaN` and every comparison against it is false; a negative
21645
+ * distance sits below the closest the container ever gets; `Infinity` is never
21646
+ * exceeded. The first two stop following and flag every append as new, and the last
21647
+ * never stops following — it takes the reading position the flag exists to protect.
21648
+ * Zero is a real declaration: it pins at the exact bottom only.
21649
+ */
21650
+ get #threshold() {
21651
+ const declared = this.thresholdValue;
21652
+ return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THRESHOLD;
20911
21653
  }
20912
21654
  /**
20913
21655
  * Whether the container has a box to scroll and to measure. One that is not rendered
@@ -20918,23 +21660,25 @@ var StickToBottomController = class extends Controller {
20918
21660
  return this.element.clientHeight > 0;
20919
21661
  }
20920
21662
  /**
20921
- * Holds the `pinOnConnect` jump until the container is laid out, then runs it and
20922
- * re-reads the state — otherwise the panel opens at the top still claiming the bottom.
21663
+ * Holds the pinned decision until the container is laid out — otherwise the panel opens
21664
+ * at the top while the state claims the bottom, and the appends that arrived meanwhile
21665
+ * were followed into a box that could not move rather than flagged.
20923
21666
  */
20924
- #pinWhenLaidOut() {
20925
- if (typeof ResizeObserver === "undefined") return;
20926
- this.#layout = new ResizeObserver(() => {
20927
- if (!this.#measurable()) return;
20928
- this.#stopWaitingForLayout();
20929
- this.#scrollToBottom("instant");
20930
- this.#updatePinned();
20931
- });
21667
+ #waitForLayout() {
21668
+ this.#awaitingLayout = true;
20932
21669
  this.#layout.observe(this.element);
20933
21670
  }
20934
- /** Releases the layout watch, whether or not the deferred jump ever ran. */
21671
+ /** Runs the held decision once the container has the box it was waiting for. */
21672
+ #onLaidOut() {
21673
+ if (!this.#awaitingLayout || !this.#measurable()) return;
21674
+ this.#stopWaitingForLayout();
21675
+ if (this.pinOnConnectValue) this.#scrollToBottom("instant");
21676
+ this.#updatePinned();
21677
+ }
21678
+ /** Releases the layout watch, whether or not the held decision ever ran. */
20935
21679
  #stopWaitingForLayout() {
20936
- this.#layout?.disconnect();
20937
- this.#layout = null;
21680
+ this.#awaitingLayout = false;
21681
+ this.#layout.disconnect();
20938
21682
  }
20939
21683
  /**
20940
21684
  * Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
@@ -20950,9 +21694,32 @@ var StickToBottomController = class extends Controller {
20950
21694
  this.element.scrollTop = top;
20951
21695
  }
20952
21696
  }
20953
- /** The append-watched element: the `content` target, or the container itself. */
20954
- #watched() {
20955
- return this.hasContentTarget ? this.contentTarget : this.element;
21697
+ /**
21698
+ * Points the append watch at the current `content` target, or at the container when
21699
+ * there is none. Re-resolved whenever that target changes, so a swap does not leave the
21700
+ * observer holding a detached node whose appends nobody sees.
21701
+ *
21702
+ * Stimulus runs the target callbacks outside the connected window too — before
21703
+ * `connect()` for a target already in the DOM, and after `disconnect()` while the
21704
+ * element is torn down — where this would arm an observer nothing releases. Re-syncing
21705
+ * to the target already held is left alone, so an arrival still in flight is not
21706
+ * dropped with the observer that was about to deliver it.
21707
+ */
21708
+ #syncWatched() {
21709
+ if (!this.#connected) return;
21710
+ const next = this.hasContentTarget ? this.contentTarget : this.element;
21711
+ if (next === this.#watched) return;
21712
+ this.#stopWatching();
21713
+ this.#watched = next;
21714
+ if (typeof MutationObserver === "undefined") return;
21715
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
21716
+ this.#observer.observe(next, { childList: true });
21717
+ }
21718
+ /** Releases the append watch and the element it held. */
21719
+ #stopWatching() {
21720
+ this.#observer?.disconnect();
21721
+ this.#observer = null;
21722
+ this.#watched = null;
20956
21723
  }
20957
21724
  /**
20958
21725
  * The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at
@@ -21944,35 +22711,48 @@ var TextareaAutosizeController = class extends Controller {
21944
22711
  };
21945
22712
  var MODES = ["light", "dark", "system"];
21946
22713
  var isMode = (value) => typeof value === "string" && MODES.includes(value);
22714
+ var DEFAULT_MODE = "system";
22715
+ var DEFAULT_TARGET = "html";
21947
22716
  var ThemeController = class extends Controller {
21948
22717
  static targets = ["option"];
21949
22718
  static values = {
21950
- mode: { type: String, default: "system" },
22719
+ mode: { type: String, default: DEFAULT_MODE },
21951
22720
  storageKey: { type: String, default: "stimeo-theme" },
21952
- target: { type: String, default: "html" }
22721
+ target: { type: String, default: DEFAULT_TARGET }
21953
22722
  };
21954
22723
  static actions = ["set", "toggle"];
21955
22724
  static events = ["change"];
21956
22725
  /** The OS dark-mode query, watched so `system` tracks live changes. */
21957
22726
  #media = null;
22727
+ /** Gate for the target callbacks, which Stimulus runs before `connect()`. */
22728
+ #connected = false;
22729
+ /** The `target` declaration after validation; the default when unparsable. */
22730
+ #targetSelector = DEFAULT_TARGET;
22731
+ /** Owns the single Tab stop across the option set (APG radiogroup). */
22732
+ #roving = new RovingTabindex(() => this.optionTargets);
22733
+ /**
22734
+ * The pair last reported, so a move can be told from a repeat. Neither side is
22735
+ * readable after the fact — assigning the Value updates the mode before any
22736
+ * comparison, and the OS query has already flipped by the time it notifies —
22737
+ * so what was reported has to be kept rather than recomputed.
22738
+ */
22739
+ #published = {
22740
+ mode: DEFAULT_MODE,
22741
+ resolved: "light"
22742
+ };
21958
22743
  /** Re-resolves while in `system` mode when the OS preference flips. */
21959
22744
  #onMediaChange = () => {
21960
- if (this.modeValue === "system") {
21961
- this.#applyTheme();
21962
- this.#syncControls();
21963
- this.#dispatchChange();
21964
- }
22745
+ if (this.#mode !== "system") return;
22746
+ this.#commit();
21965
22747
  };
21966
22748
  /** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
21967
22749
  #onKeydown = (event) => {
21968
22750
  if (event.defaultPrevented) return;
21969
22751
  if (isReservedArrowChord(event)) return;
21970
22752
  const options = this.optionTargets;
21971
- if (options.length === 0) return;
21972
22753
  const target = event.target;
21973
22754
  const current = options.indexOf(target);
21974
22755
  if (current === -1) return;
21975
- const last = options.length - 1;
21976
22756
  let next = current;
21977
22757
  const step = logicalArrowStep(event.key, this.element);
21978
22758
  switch (event.key) {
@@ -21980,13 +22760,12 @@ var ThemeController = class extends Controller {
21980
22760
  case "ArrowRight":
21981
22761
  case "ArrowUp":
21982
22762
  case "ArrowLeft":
21983
- next = step === 1 ? current === last ? 0 : current + 1 : current === 0 ? last : current - 1;
22763
+ next = rovingMove(current, options.length, step, "wrap");
21984
22764
  break;
21985
22765
  case "Home":
21986
- next = 0;
21987
- break;
21988
22766
  case "End":
21989
- next = last;
22767
+ if (hasModifierChord(event)) return;
22768
+ next = event.key === "Home" ? 0 : options.length - 1;
21990
22769
  break;
21991
22770
  default:
21992
22771
  return;
@@ -21995,25 +22774,58 @@ var ThemeController = class extends Controller {
21995
22774
  const option = options[next];
21996
22775
  if (!option) return;
21997
22776
  option.focus();
21998
- this.#setMode(this.#optionMode(option));
22777
+ const mode = this.#optionMode(option);
22778
+ if (mode) this.#setMode(mode);
21999
22779
  };
22000
22780
  connect() {
22001
22781
  const stored = this.#readStored();
22002
22782
  if (stored) this.modeValue = stored;
22003
22783
  this.#media = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
22004
22784
  this.#media?.addEventListener("change", this.#onMediaChange);
22005
- if (this.hasOptionTarget) this.element.addEventListener("keydown", this.#onKeydown);
22785
+ this.element.addEventListener("keydown", this.#onKeydown);
22786
+ this.#connected = true;
22006
22787
  this.#applyTheme();
22007
22788
  this.#syncControls();
22789
+ this.#published = this.#current;
22008
22790
  }
22009
22791
  disconnect() {
22792
+ this.#connected = false;
22010
22793
  this.#media?.removeEventListener("change", this.#onMediaChange);
22011
22794
  this.element.removeEventListener("keydown", this.#onKeydown);
22012
22795
  }
22013
- /** Selects an explicit mode from the `mode` action param (radiogroup option). */
22796
+ /** Validates the `target` declaration once, so the render path never parses. */
22797
+ targetValueChanged() {
22798
+ const selector = this.targetValue;
22799
+ if (selector.length > 0) {
22800
+ try {
22801
+ this.element.matches(selector);
22802
+ this.#targetSelector = selector;
22803
+ return;
22804
+ } catch {
22805
+ }
22806
+ }
22807
+ this.#targetSelector = DEFAULT_TARGET;
22808
+ }
22809
+ /** Re-derives the single Tab stop and ARIA for an option set that changed. */
22810
+ optionTargetConnected() {
22811
+ if (this.#connected) this.#syncControls();
22812
+ }
22813
+ /** Re-derives them again when an option leaves, so a Tab stop always remains. */
22814
+ optionTargetDisconnected() {
22815
+ if (this.#connected) this.#syncControls();
22816
+ }
22817
+ /**
22818
+ * Selects the mode the activated option declares.
22819
+ *
22820
+ * Read through {@link ThemeController.#optionMode}, the same lane that decides
22821
+ * which option is checked, so the two can never disagree about what an option
22822
+ * declares.
22823
+ */
22014
22824
  set(event) {
22015
- const mode = event.params?.mode;
22016
- if (isMode(mode)) this.#setMode(mode);
22825
+ const option = event.currentTarget;
22826
+ if (!(option instanceof HTMLElement)) return;
22827
+ const mode = this.#optionMode(option);
22828
+ if (mode) this.#setMode(mode);
22017
22829
  }
22018
22830
  /** Toggles light↔dark for the 2-value single-button contract. */
22019
22831
  toggle() {
@@ -22023,9 +22835,26 @@ var ThemeController = class extends Controller {
22023
22835
  #setMode(mode) {
22024
22836
  this.modeValue = mode;
22025
22837
  this.#writeStored(mode);
22838
+ this.#commit();
22839
+ }
22840
+ /**
22841
+ * Applies the current mode and reports it, but reports only a real move: the
22842
+ * event means "the selection or the effective theme moved", so re-choosing the
22843
+ * option already chosen is not one.
22844
+ */
22845
+ #commit() {
22026
22846
  this.#applyTheme();
22027
22847
  this.#syncControls();
22028
- this.#dispatchChange();
22848
+ const next = this.#current;
22849
+ const last = this.#published;
22850
+ this.#published = next;
22851
+ if (last.mode !== next.mode || last.resolved !== next.resolved) {
22852
+ this.dispatch("change", { detail: { ...next } });
22853
+ }
22854
+ }
22855
+ /** The pair the `change` detail carries, read from current state. */
22856
+ get #current() {
22857
+ return { mode: this.#mode, resolved: this.#resolved() };
22029
22858
  }
22030
22859
  /** Writes `data-theme` + `color-scheme` (the resolved theme) onto the target. */
22031
22860
  #applyTheme() {
@@ -22039,39 +22868,45 @@ var ThemeController = class extends Controller {
22039
22868
  #syncControls() {
22040
22869
  const options = this.optionTargets;
22041
22870
  if (options.length > 0) {
22042
- let hasTabbable = false;
22043
- for (const option of options) {
22044
- const selected = this.#optionMode(option) === this.modeValue;
22045
- option.setAttribute("aria-checked", String(selected));
22046
- option.tabIndex = selected ? 0 : -1;
22047
- hasTabbable ||= selected;
22048
- }
22049
- const first = options[0];
22050
- if (!hasTabbable && first) first.tabIndex = 0;
22871
+ const mode = this.#mode;
22872
+ let selected = -1;
22873
+ options.forEach((option, index) => {
22874
+ const isSelected = this.#optionMode(option) === mode;
22875
+ option.setAttribute("aria-checked", String(isSelected));
22876
+ if (isSelected && selected === -1) selected = index;
22877
+ });
22878
+ this.#roving.setActive(selected === -1 ? 0 : selected, { items: options });
22051
22879
  return;
22052
22880
  }
22053
- this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
22881
+ if (this.#isToggleButton) {
22882
+ this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
22883
+ }
22054
22884
  }
22055
- /** Emits `change` with the selected mode and the resolved theme. */
22056
- #dispatchChange() {
22057
- this.dispatch("change", { detail: { mode: this.modeValue, resolved: this.#resolved() } });
22885
+ /** Whether the controller element is the button of the 2-value contract. */
22886
+ get #isToggleButton() {
22887
+ return this.element.tagName === "BUTTON" || this.element.getAttribute("role") === "button";
22888
+ }
22889
+ /** The selected mode after validation; an unreadable declaration is the default. */
22890
+ get #mode() {
22891
+ return isMode(this.modeValue) ? this.modeValue : DEFAULT_MODE;
22058
22892
  }
22059
22893
  /** The effective theme: the OS preference when `system`, else the mode itself. */
22060
22894
  #resolved() {
22061
- if (this.modeValue === "dark") return "dark";
22062
- if (this.modeValue === "light") return "light";
22895
+ const mode = this.#mode;
22896
+ if (mode === "dark") return "dark";
22897
+ if (mode === "light") return "light";
22063
22898
  return this.#media?.matches ? "dark" : "light";
22064
22899
  }
22065
- /** Reads an option's mode from its action param attribute. */
22900
+ /** An option's mode from its `data-value`, or `null` when that is not one of the three. */
22066
22901
  #optionMode(option) {
22067
- const mode = option.getAttribute("data-stimeo--theme-mode-param");
22068
- return isMode(mode) ? mode : "system";
22902
+ const mode = option.getAttribute("data-value");
22903
+ return isMode(mode) ? mode : null;
22069
22904
  }
22070
22905
  /** Resolves the state-hook target (`<html>` by default). */
22071
22906
  #targetElement() {
22072
- if (this.targetValue === "html" || this.targetValue === ":root")
22073
- return document.documentElement;
22074
- return document.querySelector(this.targetValue);
22907
+ const selector = this.#targetSelector;
22908
+ if (selector === DEFAULT_TARGET || selector === ":root") return document.documentElement;
22909
+ return document.querySelector(selector);
22075
22910
  }
22076
22911
  /** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
22077
22912
  #readStored() {
@@ -22528,8 +23363,8 @@ var ToastController = class extends Controller {
22528
23363
  }
22529
23364
  /**
22530
23365
  * Stimulus lifecycle callback triggered automatically when a new item target
22531
- * enters the DOM. Perfectly handles dynamic client-side injections and server-side
22532
- * Turbo Stream appends alike.
23366
+ * enters the DOM, from a client-side injection or a Turbo Stream append alike. An
23367
+ * item already leaving, or parented outside `list`, is skipped.
22533
23368
  */
22534
23369
  itemTargetConnected(element) {
22535
23370
  this.enforceMaxLimit();
@@ -23146,7 +23981,7 @@ var ToggleGroupController = class extends Controller {
23146
23981
  return item.getAttribute("data-value") ?? "";
23147
23982
  }
23148
23983
  };
23149
- var STATE_ATTRIBUTES2 = ["disabled", "hidden"];
23984
+ var STATE_ATTRIBUTES3 = ["disabled", "hidden"];
23150
23985
  var ToolbarController = class extends Controller {
23151
23986
  static targets = ["control"];
23152
23987
  static values = {
@@ -23175,7 +24010,7 @@ var ToolbarController = class extends Controller {
23175
24010
  subtree: true,
23176
24011
  childList: true,
23177
24012
  attributes: true,
23178
- attributeFilter: STATE_ATTRIBUTES2
24013
+ attributeFilter: STATE_ATTRIBUTES3
23179
24014
  });
23180
24015
  for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
23181
24016
  observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });
@@ -23495,12 +24330,21 @@ var TransitionController = class extends Controller {
23495
24330
  static events = ["entered", "left"];
23496
24331
  /** Owns the cancellable completion wait (terminal events + bounded fallback). */
23497
24332
  #transition = new TransitionCompletion();
24333
+ /** Rewinds a half-applied stage before Turbo copies the page into its snapshot. */
24334
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
23498
24335
  #rafId = null;
24336
+ /**
24337
+ * Stage classes this controller put on the element. Removing by declaration
24338
+ * instead would take a token the consumer also authored, and would strand the
24339
+ * token that was applied when a Value changes mid-transition.
24340
+ */
24341
+ #staged = /* @__PURE__ */ new Set();
23499
24342
  connect() {
23500
- this.#strip();
23501
- this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
24343
+ this.#beforeCache.activate();
24344
+ this.#settleState();
23502
24345
  }
23503
24346
  disconnect() {
24347
+ this.#beforeCache.deactivate();
23504
24348
  this.#cancel();
23505
24349
  }
23506
24350
  /** Shows the element with the enter transition. */
@@ -23530,6 +24374,7 @@ var TransitionController = class extends Controller {
23530
24374
  const from = isEnter ? this.enterFromValue : this.leaveFromValue;
23531
24375
  const to = isEnter ? this.enterToValue : this.leaveToValue;
23532
24376
  this.#add(base, from);
24377
+ void this.element.offsetWidth;
23533
24378
  this.#rafId = this.#raf(() => {
23534
24379
  this.#rafId = null;
23535
24380
  this.#remove(from);
@@ -23551,6 +24396,21 @@ var TransitionController = class extends Controller {
23551
24396
  this.dispatch("left", { detail: {} });
23552
24397
  }
23553
24398
  }
24399
+ /** Writes the state hook the element's visibility implies. */
24400
+ #settleState() {
24401
+ this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
24402
+ }
24403
+ /**
24404
+ * Returns the element to a settled state before Turbo copies the page.
24405
+ *
24406
+ * The snapshot is taken while the controller is still connected, so stripping on
24407
+ * the next `connect()` would only repair the page after it has been painted from
24408
+ * the cache. The pass is silent: `connect()` derives the state again on restore.
24409
+ */
24410
+ #rewindForCache() {
24411
+ this.#cancel();
24412
+ this.#settleState();
24413
+ }
23554
24414
  /** Cancels any in-flight transition (interruption / teardown). */
23555
24415
  #cancel() {
23556
24416
  if (this.#rafId !== null) {
@@ -23560,24 +24420,34 @@ var TransitionController = class extends Controller {
23560
24420
  this.#transition.cancel();
23561
24421
  this.#strip();
23562
24422
  }
24423
+ /**
24424
+ * Applies the stage tokens this controller does not already find on the
24425
+ * element, and claims exactly those.
24426
+ *
24427
+ * A token already on the element is left unclaimed: it is either the consumer's
24428
+ * standing class or one an earlier stage of this transition already claimed, and
24429
+ * in neither case may this call take ownership of it. That is what keeps a
24430
+ * standing class the consumer also named as a stage Value; the cost is that such
24431
+ * a token cannot be staged, so the property it drives resolves from their CSS.
24432
+ */
23563
24433
  #add(...lists) {
23564
- const tokens = lists.flatMap(tokensOf);
23565
- if (tokens.length > 0) this.element.classList.add(...tokens);
24434
+ for (const token of lists.flatMap(tokensOf)) {
24435
+ if (this.element.classList.contains(token)) continue;
24436
+ this.element.classList.add(token);
24437
+ this.#staged.add(token);
24438
+ }
23566
24439
  }
24440
+ /** Drops the named tokens that are this controller's to drop. */
23567
24441
  #remove(...lists) {
23568
- const tokens = lists.flatMap(tokensOf);
23569
- if (tokens.length > 0) this.element.classList.remove(...tokens);
24442
+ for (const token of lists.flatMap(tokensOf)) {
24443
+ if (!this.#staged.delete(token)) continue;
24444
+ this.element.classList.remove(token);
24445
+ }
23570
24446
  }
23571
- /** Removes every stage class so no half-applied state lingers. */
24447
+ /** Removes every stage class this controller applied, so none lingers. */
23572
24448
  #strip() {
23573
- this.#remove(
23574
- this.enterValue,
23575
- this.enterFromValue,
23576
- this.enterToValue,
23577
- this.leaveValue,
23578
- this.leaveFromValue,
23579
- this.leaveToValue
23580
- );
24449
+ this.element.classList.remove(...this.#staged);
24450
+ this.#staged.clear();
23581
24451
  }
23582
24452
  #raf(callback) {
23583
24453
  if (typeof window.requestAnimationFrame === "function") {