stimeo-ui 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/dist/controllers/alert_dialog_controller.js +17 -7
  4. data/dist/controllers/announcer_controller.js +1 -1
  5. data/dist/controllers/bulk_select_controller.js +2 -2
  6. data/dist/controllers/collapsible_controller.js +1 -1
  7. data/dist/controllers/command_palette_controller.js +15 -5
  8. data/dist/controllers/confirm_controller.js +15 -5
  9. data/dist/controllers/countdown_controller.js +1 -1
  10. data/dist/controllers/data_grid_controller.js +1 -1
  11. data/dist/controllers/dialog_controller.js +15 -5
  12. data/dist/controllers/direct_upload_controller.js +1 -5
  13. data/dist/controllers/drawer_controller.js +17 -7
  14. data/dist/controllers/focus_controller.js +45 -7
  15. data/dist/controllers/form_field_controller.js +1 -1
  16. data/dist/controllers/menubar_controller.js +2 -2
  17. data/dist/controllers/multi_select_controller.js +1 -1
  18. data/dist/controllers/overflow_menu_controller.js +2 -2
  19. data/dist/controllers/password_reveal_controller.js +102 -3
  20. data/dist/controllers/password_strength_controller.js +287 -42
  21. data/dist/controllers/resizable_controller.js +1 -1
  22. data/dist/controllers/scroll_restore_controller.js +127 -27
  23. data/dist/controllers/scroll_visibility_controller.js +167 -14
  24. data/dist/controllers/sidebar_controller.js +15 -5
  25. data/dist/controllers/step_indicator_controller.js +0 -5
  26. data/dist/controllers/theme_controller.js +151 -40
  27. data/dist/controllers/toast_controller.js +2 -2
  28. data/dist/controllers/transition_controller.js +79 -15
  29. data/dist/index.js +740 -178
  30. data/dist/positioning/index.js +84 -25
  31. data/lib/stimeo/ui/version.rb +1 -1
  32. 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
@@ -18326,7 +18592,7 @@ var ResizableController = class extends Controller {
18326
18592
  this.#dispatchChange();
18327
18593
  }
18328
18594
  }
18329
- /** Double-click or Enter to collapse the primary pane, or put it back. */
18595
+ /** Collapses the primary pane to its minimum, or returns it to the last position. */
18330
18596
  toggle() {
18331
18597
  const { min, max } = this.#range;
18332
18598
  if (this.#position > min) {
@@ -18908,6 +19174,17 @@ var ScrollAreaController = class extends Controller {
18908
19174
  this.#clearHostState();
18909
19175
  }
18910
19176
  };
19177
+ function parseStored(raw) {
19178
+ if (raw === null) return null;
19179
+ try {
19180
+ return JSON.parse(raw);
19181
+ } catch {
19182
+ return null;
19183
+ }
19184
+ }
19185
+ function storedOffset(value) {
19186
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
19187
+ }
18911
19188
  var ScrollRestoreController = class extends Controller {
18912
19189
  static values = {
18913
19190
  key: { type: String, default: "" },
@@ -18920,6 +19197,26 @@ var ScrollRestoreController = class extends Controller {
18920
19197
  /** Last offset captured while the element was live; persisted as-is on teardown. */
18921
19198
  #lastTop = 0;
18922
19199
  #lastLeft = 0;
19200
+ /**
19201
+ * The offsets this controller's own restore produced, awaiting the `scroll`
19202
+ * the engine fires for them. A restore the layout cannot reach yet lands short,
19203
+ * so treating that echo as the reader's position would cut the saved offset
19204
+ * down to whatever the unfinished layout allowed.
19205
+ */
19206
+ #echoTop = null;
19207
+ #echoLeft = null;
19208
+ /** Offsets stored for an axis that is not tracked, carried through saves. */
19209
+ #carried = {};
19210
+ /**
19211
+ * Whether each axis holds an offset worth saving — one this namespace restored
19212
+ * or the reader moved. A save writes only the axes that are marked, so an
19213
+ * offset taken under a different key or axis is never re-published as if the
19214
+ * reader had left it there.
19215
+ */
19216
+ #capturedTop = false;
19217
+ #capturedLeft = false;
19218
+ /** Whether the controller is connected; Value callbacks outside that window do not resync. */
19219
+ #connected = false;
18923
19220
  #onScroll = () => {
18924
19221
  this.#capture();
18925
19222
  if (this.#rafId !== null) return;
@@ -18929,58 +19226,127 @@ var ScrollRestoreController = class extends Controller {
18929
19226
  });
18930
19227
  };
18931
19228
  connect() {
19229
+ this.#connected = true;
18932
19230
  this.#storageKey = this.#resolveKey();
18933
- if (!this.#storageKey) return;
18934
- this.#restore();
18935
19231
  this.element.addEventListener("scroll", this.#onScroll, { passive: true });
19232
+ if (this.#storageKey) this.#restore();
18936
19233
  }
18937
19234
  disconnect() {
18938
- if (!this.#storageKey) return;
19235
+ this.#connected = false;
18939
19236
  this.element.removeEventListener("scroll", this.#onScroll);
19237
+ this.#cancelFrame();
19238
+ this.#persist();
19239
+ }
19240
+ /** Re-derives the namespace when application code or a Turbo morph moves `key`. */
19241
+ keyValueChanged() {
19242
+ this.#resync();
19243
+ }
19244
+ /** Re-derives the tracked axes when application code or a Turbo morph moves `axis`. */
19245
+ axisValueChanged() {
19246
+ this.#resync();
19247
+ }
19248
+ /** Rebuilds the persistence state around the Values as they now read. */
19249
+ #resync() {
19250
+ if (!this.#connected) return;
19251
+ this.#cancelFrame();
19252
+ this.#storageKey = this.#resolveKey();
19253
+ this.#echoTop = null;
19254
+ this.#echoLeft = null;
19255
+ this.#carried = {};
19256
+ this.#capturedTop = false;
19257
+ this.#capturedLeft = false;
19258
+ if (this.#storageKey) this.#restore();
19259
+ }
19260
+ /** Drops the pending coalesced save, if one is queued. */
19261
+ #cancelFrame() {
18940
19262
  if (this.#rafId !== null) {
18941
19263
  cancelAnimationFrame(this.#rafId);
18942
19264
  this.#rafId = null;
18943
19265
  }
18944
- this.#persist();
18945
19266
  }
18946
- /** Records the live scroll offset for the configured axis. */
19267
+ /**
19268
+ * Records the live scroll offset for the configured axis.
19269
+ *
19270
+ * An axis holds its echo until that axis actually moves. A scroll event names
19271
+ * no axis, so consuming both on the first one to arrive would leave the other
19272
+ * unguarded: on `both`, moving one axis would take the clamped reading of the
19273
+ * untouched axis as the reader's own position and save it, cutting the stored
19274
+ * offset down to whatever the layout could reach at restore time.
19275
+ */
18947
19276
  #capture() {
18948
- if (this.#tracksVertical) this.#lastTop = this.element.scrollTop;
18949
- if (this.#tracksHorizontal) this.#lastLeft = this.element.scrollLeft;
19277
+ if (this.#tracksVertical) {
19278
+ const top = this.element.scrollTop;
19279
+ if (top !== this.#echoTop) {
19280
+ this.#echoTop = null;
19281
+ this.#lastTop = top;
19282
+ this.#capturedTop = true;
19283
+ }
19284
+ }
19285
+ if (this.#tracksHorizontal) {
19286
+ const left = this.element.scrollLeft;
19287
+ if (left !== this.#echoLeft) {
19288
+ this.#echoLeft = null;
19289
+ this.#lastLeft = left;
19290
+ this.#capturedLeft = true;
19291
+ }
19292
+ }
18950
19293
  }
18951
- /** Persists the last captured scroll offset for the configured axis. */
19294
+ /**
19295
+ * Writes the marked axes, carrying through any offset held for an untracked one.
19296
+ * Without a key, or with neither axis marked, it writes nothing.
19297
+ */
18952
19298
  #persist() {
18953
- const data = {};
18954
- if (this.#tracksVertical) data.top = this.#lastTop;
18955
- if (this.#tracksHorizontal) data.left = this.#lastLeft;
19299
+ if (!this.#storageKey || !(this.#capturedTop || this.#capturedLeft)) return;
19300
+ const data = { ...this.#carried };
19301
+ if (this.#tracksVertical && this.#capturedTop) data.top = this.#lastTop;
19302
+ if (this.#tracksHorizontal && this.#capturedLeft) data.left = this.#lastLeft;
18956
19303
  try {
18957
- sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
19304
+ window.sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
18958
19305
  } catch {
18959
19306
  }
18960
19307
  }
18961
19308
  /** Applies the persisted scroll offset, if any, without moving focus. */
18962
19309
  #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;
19310
+ let raw;
18971
19311
  try {
18972
- data = JSON.parse(raw);
19312
+ raw = window.sessionStorage.getItem(this.#storageKey);
18973
19313
  } catch {
18974
19314
  return;
18975
19315
  }
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;
19316
+ const data = parseStored(raw);
19317
+ if (data === null) return;
19318
+ this.#echoTop = null;
19319
+ this.#echoLeft = null;
19320
+ const top = storedOffset(data.top);
19321
+ const left = storedOffset(data.left);
19322
+ const options = { behavior: "instant" };
19323
+ let requested = false;
19324
+ if (this.#tracksVertical) {
19325
+ if (top !== null) {
19326
+ options.top = top;
19327
+ this.#lastTop = top;
19328
+ this.#capturedTop = true;
19329
+ requested = true;
19330
+ }
19331
+ } else if (data.top !== void 0) {
19332
+ this.#carried.top = data.top;
19333
+ }
19334
+ if (this.#tracksHorizontal) {
19335
+ if (left !== null) {
19336
+ options.left = left;
19337
+ this.#lastLeft = left;
19338
+ this.#capturedLeft = true;
19339
+ requested = true;
19340
+ }
19341
+ } else if (data.left !== void 0) {
19342
+ this.#carried.left = data.left;
18983
19343
  }
19344
+ if (!requested) return;
19345
+ const beforeTop = this.element.scrollTop;
19346
+ const beforeLeft = this.element.scrollLeft;
19347
+ this.element.scrollTo(options);
19348
+ if (this.element.scrollTop !== beforeTop) this.#echoTop = this.element.scrollTop;
19349
+ if (this.element.scrollLeft !== beforeLeft) this.#echoLeft = this.element.scrollLeft;
18984
19350
  }
18985
19351
  /** The `sessionStorage` key: explicit `key`, else the element `id`, else none. */
18986
19352
  #resolveKey() {
@@ -18994,10 +19360,11 @@ var ScrollRestoreController = class extends Controller {
18994
19360
  return this.axisValue === "horizontal" || this.axisValue === "both";
18995
19361
  }
18996
19362
  };
19363
+ var DEFAULT_OFFSET = 400;
18997
19364
  var ScrollVisibilityController = class extends Controller {
18998
19365
  static targets = ["element"];
18999
19366
  static values = {
19000
- offset: { type: Number, default: 400 },
19367
+ offset: { type: Number, default: DEFAULT_OFFSET },
19001
19368
  mode: { type: String, default: "offset" },
19002
19369
  focusSelector: { type: String, default: "" },
19003
19370
  root: { type: String, default: "" }
@@ -19015,8 +19382,32 @@ var ScrollVisibilityController = class extends Controller {
19015
19382
  * the window. Captured on connect so teardown detaches from the same source.
19016
19383
  */
19017
19384
  #scrollSource = window;
19385
+ /**
19386
+ * Gates the declaration callbacks to the connected window.
19387
+ *
19388
+ * Stimulus delivers a Value callback ahead of `connect()` and again for every
19389
+ * runtime change; without the gate, merely connecting would evaluate — and
19390
+ * announce — before `connect()` runs its own first reflection.
19391
+ */
19392
+ #connected = false;
19393
+ /** Validated threshold; a non-finite declaration reads as the default. */
19394
+ #offset = DEFAULT_OFFSET;
19395
+ /** Validated `root` selector; an unparsable declaration reads as absent. */
19396
+ #rootSelector = "";
19397
+ /** Validated `focusSelector`; an unparsable declaration reads as absent. */
19398
+ #focusSelector = "";
19018
19399
  /** Focus targets this instance lent a `tabindex` to. */
19019
19400
  #tabindex = new TabindexLoan();
19401
+ /**
19402
+ * The hide held back while the control itself owns focus.
19403
+ *
19404
+ * Completing the deferral re-runs the ordinary evaluation rather than applying
19405
+ * the stale decision: by the time focus leaves, the scroll position may have
19406
+ * moved back past the threshold.
19407
+ */
19408
+ #pendingHide = new BlurDeferral(() => {
19409
+ if (this.#connected) this.#evaluate();
19410
+ });
19020
19411
  #onScroll = () => {
19021
19412
  if (this.#rafId !== null) return;
19022
19413
  this.#rafId = requestAnimationFrame(() => {
@@ -19028,61 +19419,134 @@ var ScrollVisibilityController = class extends Controller {
19028
19419
  this.#scrollSource = this.#resolveScrollSource();
19029
19420
  this.#lastScrollY = this.#scrollY();
19030
19421
  this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
19031
- this.#evaluate();
19422
+ this.#evaluate(false);
19423
+ this.#connected = true;
19032
19424
  }
19033
19425
  disconnect() {
19426
+ this.#connected = false;
19034
19427
  this.#scrollSource.removeEventListener("scroll", this.#onScroll);
19035
19428
  if (this.#rafId !== null) {
19036
19429
  cancelAnimationFrame(this.#rafId);
19037
19430
  this.#rafId = null;
19038
19431
  }
19432
+ this.#pendingHide.releaseAll();
19039
19433
  this.#tabindex.returnAll();
19040
19434
  this.#visible = null;
19041
19435
  }
19436
+ /** Writes the current visibility onto a control that arrives after connect. */
19437
+ elementTargetConnected(element) {
19438
+ if (this.#visible !== null) element.hidden = !this.#visible;
19439
+ }
19440
+ /** Drops a held-back hide together with the control it was waiting on. */
19441
+ elementTargetDisconnected() {
19442
+ this.#pendingHide.releaseAll();
19443
+ }
19444
+ /**
19445
+ * Validates `offset` once, then re-renders.
19446
+ *
19447
+ * Re-renders when application code (or a Turbo morph) changes `offset` at
19448
+ * runtime. A declaration that is not a finite number reads as the default, so
19449
+ * the comparison path never sees `NaN` — which would answer `false` to every
19450
+ * comparison and strand the element (in `direction` mode, even the guarantee
19451
+ * that the very top always reveals).
19452
+ */
19453
+ offsetValueChanged() {
19454
+ this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
19455
+ if (this.#connected) this.#evaluate();
19456
+ }
19457
+ /** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */
19458
+ modeValueChanged() {
19459
+ if (this.#connected) this.#evaluate();
19460
+ }
19461
+ /** Validates `root` once so connect never parses a selector that throws. */
19462
+ rootValueChanged() {
19463
+ this.#rootSelector = this.#validSelector(this.rootValue);
19464
+ }
19465
+ /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
19466
+ focusSelectorValueChanged() {
19467
+ this.#focusSelector = this.#validSelector(this.focusSelectorValue);
19468
+ }
19042
19469
  /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
19043
19470
  toTop() {
19044
19471
  const behavior = prefersReducedMotion() ? "instant" : "smooth";
19045
19472
  this.#scrollSource.scrollTo({ top: 0, behavior });
19046
- if (this.focusSelectorValue) {
19047
- const target = document.querySelector(this.focusSelectorValue);
19473
+ if (this.#focusSelector) {
19474
+ const target = document.querySelector(this.#focusSelector);
19048
19475
  if (target) {
19049
19476
  this.#tabindex.lend(target);
19050
- target.focus();
19477
+ target.focus({ preventScroll: true });
19051
19478
  }
19052
19479
  }
19053
19480
  }
19054
- /** Decides the next visibility from the current scroll state and applies it. */
19055
- #evaluate() {
19481
+ /**
19482
+ * Decides the next visibility from the current scroll state and applies it.
19483
+ *
19484
+ * @param notify - whether a transition announces itself. The reflection
19485
+ * `connect()` performs is the current state, not a change.
19486
+ *
19487
+ * @stimeoRenderRoot
19488
+ */
19489
+ #evaluate(notify = true) {
19056
19490
  const y = this.#scrollY();
19057
19491
  let nextVisible;
19058
19492
  if (this.modeValue === "direction") {
19059
- if (y <= this.offsetValue) {
19493
+ if (y <= this.#offset) {
19060
19494
  nextVisible = true;
19495
+ } else if (y === this.#lastScrollY) {
19496
+ return;
19061
19497
  } else {
19062
19498
  nextVisible = y < this.#lastScrollY;
19063
19499
  }
19064
19500
  } else {
19065
- nextVisible = y > this.offsetValue;
19501
+ nextVisible = y > this.#offset;
19066
19502
  }
19067
19503
  this.#lastScrollY = y;
19068
- this.#setVisible(nextVisible);
19504
+ this.#setVisible(nextVisible, notify);
19069
19505
  }
19070
19506
  /** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */
19071
- #setVisible(next) {
19507
+ #setVisible(next, notify) {
19072
19508
  if (next === this.#visible) return;
19509
+ const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;
19510
+ if (focused) {
19511
+ this.#pendingHide.deferOnly(focused);
19512
+ return;
19513
+ }
19073
19514
  this.#visible = next;
19074
19515
  if (this.hasElementTarget) this.elementTarget.hidden = !next;
19075
19516
  this.element.setAttribute("data-state", next ? "visible" : "hidden");
19076
- this.dispatch("change", { detail: { visible: next } });
19517
+ if (notify) this.dispatch("change", { detail: { visible: next } });
19077
19518
  }
19078
19519
  /** Resolves the scroll source from `root` (falling back to the window). */
19079
19520
  #resolveScrollSource() {
19080
- if (this.rootValue) {
19081
- const root = document.querySelector(this.rootValue);
19521
+ if (this.#rootSelector) {
19522
+ const root = document.querySelector(this.#rootSelector);
19082
19523
  if (root) return root;
19083
19524
  }
19084
19525
  return window;
19085
19526
  }
19527
+ /**
19528
+ * The focus owner inside the target, or `null` when focus is elsewhere.
19529
+ *
19530
+ * `blur` does not bubble, so the deferral has to ride the focused element
19531
+ * itself: waiting on a container that never receives the event would hold the
19532
+ * hide forever.
19533
+ */
19534
+ #focusedWithin() {
19535
+ const focused = document.activeElement;
19536
+ if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
19537
+ return null;
19538
+ }
19539
+ /** Returns `declared` when it parses as a selector, and `""` when it does not. */
19540
+ #validSelector(declared) {
19541
+ if (declared.length > 0) {
19542
+ try {
19543
+ this.element.matches(declared);
19544
+ return declared;
19545
+ } catch {
19546
+ }
19547
+ }
19548
+ return "";
19549
+ }
19086
19550
  #scrollY() {
19087
19551
  if (this.#scrollSource === window) {
19088
19552
  return window.scrollY ?? window.pageYOffset ?? 0;
@@ -20650,11 +21114,6 @@ var StepIndicatorController = class extends Controller {
20650
21114
  };
20651
21115
  static actions = ["setCurrent"];
20652
21116
  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
21117
  /**
20659
21118
  * Collapses a batch of step callbacks — and a morph that swaps `current` with
20660
21119
  * them — into one repaint. Replacing a list of N steps delivers N callbacks, and
@@ -21944,35 +22403,48 @@ var TextareaAutosizeController = class extends Controller {
21944
22403
  };
21945
22404
  var MODES = ["light", "dark", "system"];
21946
22405
  var isMode = (value) => typeof value === "string" && MODES.includes(value);
22406
+ var DEFAULT_MODE = "system";
22407
+ var DEFAULT_TARGET = "html";
21947
22408
  var ThemeController = class extends Controller {
21948
22409
  static targets = ["option"];
21949
22410
  static values = {
21950
- mode: { type: String, default: "system" },
22411
+ mode: { type: String, default: DEFAULT_MODE },
21951
22412
  storageKey: { type: String, default: "stimeo-theme" },
21952
- target: { type: String, default: "html" }
22413
+ target: { type: String, default: DEFAULT_TARGET }
21953
22414
  };
21954
22415
  static actions = ["set", "toggle"];
21955
22416
  static events = ["change"];
21956
22417
  /** The OS dark-mode query, watched so `system` tracks live changes. */
21957
22418
  #media = null;
22419
+ /** Gate for the target callbacks, which Stimulus runs before `connect()`. */
22420
+ #connected = false;
22421
+ /** The `target` declaration after validation; the default when unparsable. */
22422
+ #targetSelector = DEFAULT_TARGET;
22423
+ /** Owns the single Tab stop across the option set (APG radiogroup). */
22424
+ #roving = new RovingTabindex(() => this.optionTargets);
22425
+ /**
22426
+ * The pair last reported, so a move can be told from a repeat. Neither side is
22427
+ * readable after the fact — assigning the Value updates the mode before any
22428
+ * comparison, and the OS query has already flipped by the time it notifies —
22429
+ * so what was reported has to be kept rather than recomputed.
22430
+ */
22431
+ #published = {
22432
+ mode: DEFAULT_MODE,
22433
+ resolved: "light"
22434
+ };
21958
22435
  /** Re-resolves while in `system` mode when the OS preference flips. */
21959
22436
  #onMediaChange = () => {
21960
- if (this.modeValue === "system") {
21961
- this.#applyTheme();
21962
- this.#syncControls();
21963
- this.#dispatchChange();
21964
- }
22437
+ if (this.#mode !== "system") return;
22438
+ this.#commit();
21965
22439
  };
21966
22440
  /** Arrow/Home/End navigation for the radiogroup (APG radio pattern). */
21967
22441
  #onKeydown = (event) => {
21968
22442
  if (event.defaultPrevented) return;
21969
22443
  if (isReservedArrowChord(event)) return;
21970
22444
  const options = this.optionTargets;
21971
- if (options.length === 0) return;
21972
22445
  const target = event.target;
21973
22446
  const current = options.indexOf(target);
21974
22447
  if (current === -1) return;
21975
- const last = options.length - 1;
21976
22448
  let next = current;
21977
22449
  const step = logicalArrowStep(event.key, this.element);
21978
22450
  switch (event.key) {
@@ -21980,13 +22452,12 @@ var ThemeController = class extends Controller {
21980
22452
  case "ArrowRight":
21981
22453
  case "ArrowUp":
21982
22454
  case "ArrowLeft":
21983
- next = step === 1 ? current === last ? 0 : current + 1 : current === 0 ? last : current - 1;
22455
+ next = rovingMove(current, options.length, step, "wrap");
21984
22456
  break;
21985
22457
  case "Home":
21986
- next = 0;
21987
- break;
21988
22458
  case "End":
21989
- next = last;
22459
+ if (hasModifierChord(event)) return;
22460
+ next = event.key === "Home" ? 0 : options.length - 1;
21990
22461
  break;
21991
22462
  default:
21992
22463
  return;
@@ -21995,25 +22466,58 @@ var ThemeController = class extends Controller {
21995
22466
  const option = options[next];
21996
22467
  if (!option) return;
21997
22468
  option.focus();
21998
- this.#setMode(this.#optionMode(option));
22469
+ const mode = this.#optionMode(option);
22470
+ if (mode) this.#setMode(mode);
21999
22471
  };
22000
22472
  connect() {
22001
22473
  const stored = this.#readStored();
22002
22474
  if (stored) this.modeValue = stored;
22003
22475
  this.#media = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
22004
22476
  this.#media?.addEventListener("change", this.#onMediaChange);
22005
- if (this.hasOptionTarget) this.element.addEventListener("keydown", this.#onKeydown);
22477
+ this.element.addEventListener("keydown", this.#onKeydown);
22478
+ this.#connected = true;
22006
22479
  this.#applyTheme();
22007
22480
  this.#syncControls();
22481
+ this.#published = this.#current;
22008
22482
  }
22009
22483
  disconnect() {
22484
+ this.#connected = false;
22010
22485
  this.#media?.removeEventListener("change", this.#onMediaChange);
22011
22486
  this.element.removeEventListener("keydown", this.#onKeydown);
22012
22487
  }
22013
- /** Selects an explicit mode from the `mode` action param (radiogroup option). */
22488
+ /** Validates the `target` declaration once, so the render path never parses. */
22489
+ targetValueChanged() {
22490
+ const selector = this.targetValue;
22491
+ if (selector.length > 0) {
22492
+ try {
22493
+ this.element.matches(selector);
22494
+ this.#targetSelector = selector;
22495
+ return;
22496
+ } catch {
22497
+ }
22498
+ }
22499
+ this.#targetSelector = DEFAULT_TARGET;
22500
+ }
22501
+ /** Re-derives the single Tab stop and ARIA for an option set that changed. */
22502
+ optionTargetConnected() {
22503
+ if (this.#connected) this.#syncControls();
22504
+ }
22505
+ /** Re-derives them again when an option leaves, so a Tab stop always remains. */
22506
+ optionTargetDisconnected() {
22507
+ if (this.#connected) this.#syncControls();
22508
+ }
22509
+ /**
22510
+ * Selects the mode the activated option declares.
22511
+ *
22512
+ * Read through {@link ThemeController.#optionMode}, the same lane that decides
22513
+ * which option is checked, so the two can never disagree about what an option
22514
+ * declares.
22515
+ */
22014
22516
  set(event) {
22015
- const mode = event.params?.mode;
22016
- if (isMode(mode)) this.#setMode(mode);
22517
+ const option = event.currentTarget;
22518
+ if (!(option instanceof HTMLElement)) return;
22519
+ const mode = this.#optionMode(option);
22520
+ if (mode) this.#setMode(mode);
22017
22521
  }
22018
22522
  /** Toggles light↔dark for the 2-value single-button contract. */
22019
22523
  toggle() {
@@ -22023,9 +22527,26 @@ var ThemeController = class extends Controller {
22023
22527
  #setMode(mode) {
22024
22528
  this.modeValue = mode;
22025
22529
  this.#writeStored(mode);
22530
+ this.#commit();
22531
+ }
22532
+ /**
22533
+ * Applies the current mode and reports it, but reports only a real move: the
22534
+ * event means "the selection or the effective theme moved", so re-choosing the
22535
+ * option already chosen is not one.
22536
+ */
22537
+ #commit() {
22026
22538
  this.#applyTheme();
22027
22539
  this.#syncControls();
22028
- this.#dispatchChange();
22540
+ const next = this.#current;
22541
+ const last = this.#published;
22542
+ this.#published = next;
22543
+ if (last.mode !== next.mode || last.resolved !== next.resolved) {
22544
+ this.dispatch("change", { detail: { ...next } });
22545
+ }
22546
+ }
22547
+ /** The pair the `change` detail carries, read from current state. */
22548
+ get #current() {
22549
+ return { mode: this.#mode, resolved: this.#resolved() };
22029
22550
  }
22030
22551
  /** Writes `data-theme` + `color-scheme` (the resolved theme) onto the target. */
22031
22552
  #applyTheme() {
@@ -22039,39 +22560,45 @@ var ThemeController = class extends Controller {
22039
22560
  #syncControls() {
22040
22561
  const options = this.optionTargets;
22041
22562
  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;
22563
+ const mode = this.#mode;
22564
+ let selected = -1;
22565
+ options.forEach((option, index) => {
22566
+ const isSelected = this.#optionMode(option) === mode;
22567
+ option.setAttribute("aria-checked", String(isSelected));
22568
+ if (isSelected && selected === -1) selected = index;
22569
+ });
22570
+ this.#roving.setActive(selected === -1 ? 0 : selected, { items: options });
22051
22571
  return;
22052
22572
  }
22053
- this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
22573
+ if (this.#isToggleButton) {
22574
+ this.element.setAttribute("aria-pressed", String(this.#resolved() === "dark"));
22575
+ }
22054
22576
  }
22055
- /** Emits `change` with the selected mode and the resolved theme. */
22056
- #dispatchChange() {
22057
- this.dispatch("change", { detail: { mode: this.modeValue, resolved: this.#resolved() } });
22577
+ /** Whether the controller element is the button of the 2-value contract. */
22578
+ get #isToggleButton() {
22579
+ return this.element.tagName === "BUTTON" || this.element.getAttribute("role") === "button";
22580
+ }
22581
+ /** The selected mode after validation; an unreadable declaration is the default. */
22582
+ get #mode() {
22583
+ return isMode(this.modeValue) ? this.modeValue : DEFAULT_MODE;
22058
22584
  }
22059
22585
  /** The effective theme: the OS preference when `system`, else the mode itself. */
22060
22586
  #resolved() {
22061
- if (this.modeValue === "dark") return "dark";
22062
- if (this.modeValue === "light") return "light";
22587
+ const mode = this.#mode;
22588
+ if (mode === "dark") return "dark";
22589
+ if (mode === "light") return "light";
22063
22590
  return this.#media?.matches ? "dark" : "light";
22064
22591
  }
22065
- /** Reads an option's mode from its action param attribute. */
22592
+ /** An option's mode from its `data-value`, or `null` when that is not one of the three. */
22066
22593
  #optionMode(option) {
22067
- const mode = option.getAttribute("data-stimeo--theme-mode-param");
22068
- return isMode(mode) ? mode : "system";
22594
+ const mode = option.getAttribute("data-value");
22595
+ return isMode(mode) ? mode : null;
22069
22596
  }
22070
22597
  /** Resolves the state-hook target (`<html>` by default). */
22071
22598
  #targetElement() {
22072
- if (this.targetValue === "html" || this.targetValue === ":root")
22073
- return document.documentElement;
22074
- return document.querySelector(this.targetValue);
22599
+ const selector = this.#targetSelector;
22600
+ if (selector === DEFAULT_TARGET || selector === ":root") return document.documentElement;
22601
+ return document.querySelector(selector);
22075
22602
  }
22076
22603
  /** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
22077
22604
  #readStored() {
@@ -22528,8 +23055,8 @@ var ToastController = class extends Controller {
22528
23055
  }
22529
23056
  /**
22530
23057
  * 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.
23058
+ * enters the DOM, from a client-side injection or a Turbo Stream append alike. An
23059
+ * item already leaving, or parented outside `list`, is skipped.
22533
23060
  */
22534
23061
  itemTargetConnected(element) {
22535
23062
  this.enforceMaxLimit();
@@ -23495,12 +24022,21 @@ var TransitionController = class extends Controller {
23495
24022
  static events = ["entered", "left"];
23496
24023
  /** Owns the cancellable completion wait (terminal events + bounded fallback). */
23497
24024
  #transition = new TransitionCompletion();
24025
+ /** Rewinds a half-applied stage before Turbo copies the page into its snapshot. */
24026
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
23498
24027
  #rafId = null;
24028
+ /**
24029
+ * Stage classes this controller put on the element. Removing by declaration
24030
+ * instead would take a token the consumer also authored, and would strand the
24031
+ * token that was applied when a Value changes mid-transition.
24032
+ */
24033
+ #staged = /* @__PURE__ */ new Set();
23499
24034
  connect() {
23500
- this.#strip();
23501
- this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
24035
+ this.#beforeCache.activate();
24036
+ this.#settleState();
23502
24037
  }
23503
24038
  disconnect() {
24039
+ this.#beforeCache.deactivate();
23504
24040
  this.#cancel();
23505
24041
  }
23506
24042
  /** Shows the element with the enter transition. */
@@ -23530,6 +24066,7 @@ var TransitionController = class extends Controller {
23530
24066
  const from = isEnter ? this.enterFromValue : this.leaveFromValue;
23531
24067
  const to = isEnter ? this.enterToValue : this.leaveToValue;
23532
24068
  this.#add(base, from);
24069
+ void this.element.offsetWidth;
23533
24070
  this.#rafId = this.#raf(() => {
23534
24071
  this.#rafId = null;
23535
24072
  this.#remove(from);
@@ -23551,6 +24088,21 @@ var TransitionController = class extends Controller {
23551
24088
  this.dispatch("left", { detail: {} });
23552
24089
  }
23553
24090
  }
24091
+ /** Writes the state hook the element's visibility implies. */
24092
+ #settleState() {
24093
+ this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
24094
+ }
24095
+ /**
24096
+ * Returns the element to a settled state before Turbo copies the page.
24097
+ *
24098
+ * The snapshot is taken while the controller is still connected, so stripping on
24099
+ * the next `connect()` would only repair the page after it has been painted from
24100
+ * the cache. The pass is silent: `connect()` derives the state again on restore.
24101
+ */
24102
+ #rewindForCache() {
24103
+ this.#cancel();
24104
+ this.#settleState();
24105
+ }
23554
24106
  /** Cancels any in-flight transition (interruption / teardown). */
23555
24107
  #cancel() {
23556
24108
  if (this.#rafId !== null) {
@@ -23560,24 +24112,34 @@ var TransitionController = class extends Controller {
23560
24112
  this.#transition.cancel();
23561
24113
  this.#strip();
23562
24114
  }
24115
+ /**
24116
+ * Applies the stage tokens this controller does not already find on the
24117
+ * element, and claims exactly those.
24118
+ *
24119
+ * A token already on the element is left unclaimed: it is either the consumer's
24120
+ * standing class or one an earlier stage of this transition already claimed, and
24121
+ * in neither case may this call take ownership of it. That is what keeps a
24122
+ * standing class the consumer also named as a stage Value; the cost is that such
24123
+ * a token cannot be staged, so the property it drives resolves from their CSS.
24124
+ */
23563
24125
  #add(...lists) {
23564
- const tokens = lists.flatMap(tokensOf);
23565
- if (tokens.length > 0) this.element.classList.add(...tokens);
24126
+ for (const token of lists.flatMap(tokensOf)) {
24127
+ if (this.element.classList.contains(token)) continue;
24128
+ this.element.classList.add(token);
24129
+ this.#staged.add(token);
24130
+ }
23566
24131
  }
24132
+ /** Drops the named tokens that are this controller's to drop. */
23567
24133
  #remove(...lists) {
23568
- const tokens = lists.flatMap(tokensOf);
23569
- if (tokens.length > 0) this.element.classList.remove(...tokens);
24134
+ for (const token of lists.flatMap(tokensOf)) {
24135
+ if (!this.#staged.delete(token)) continue;
24136
+ this.element.classList.remove(token);
24137
+ }
23570
24138
  }
23571
- /** Removes every stage class so no half-applied state lingers. */
24139
+ /** Removes every stage class this controller applied, so none lingers. */
23572
24140
  #strip() {
23573
- this.#remove(
23574
- this.enterValue,
23575
- this.enterFromValue,
23576
- this.enterToValue,
23577
- this.leaveValue,
23578
- this.leaveFromValue,
23579
- this.leaveToValue
23580
- );
24141
+ this.element.classList.remove(...this.#staged);
24142
+ this.#staged.clear();
23581
24143
  }
23582
24144
  #raf(callback) {
23583
24145
  if (typeof window.requestAnimationFrame === "function") {