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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a8ccbe681a75206e0bb8a8866faeb91e0f3878c6b05cba845d00a333bb87041
4
- data.tar.gz: f037b2908d8f19d55f1b9c5823a80b0459f995837b396dcc686b6b9318ee58dc
3
+ metadata.gz: 9426006a2ca0153a2f91db44ca72beff51851141b42ab82bf64f55a68f853651
4
+ data.tar.gz: fdbd9afa0e2c79f74a823c69b04f55295626fec0b8315e3fec7ec1f88f0406d8
5
5
  SHA512:
6
- metadata.gz: 785070e68e5d1cad2c96d1ca1821096662d2612a42121c254d5d0b92400b2e4f04af759fb1ad8827c1100dad712a43e5c0d45032aa72449d54956d3bf606f982
7
- data.tar.gz: 2e1632573e162636ffd659fb01b0cc0c5d5d22d0d521e6f9c39793a28023f0403eb3008fd5640d65f73b74d7d19ce210d01b7413b459db3fa5e6bbf8bcb58dce
6
+ metadata.gz: ac9a8e4008547f68a709b27964c767bae3adf601770a353b6834e58866f6d84ce68f257cb0d5a8e80aa685cbc2a12e4068414f8351952e2d9afeaf1582764d7f
7
+ data.tar.gz: ac6559ced3a65c78f00b836d8a5f6a6c7aed7dfe50b091b13f537418a32f9e76d5bf00c487faa23d13851670069dd2d7a1bfdb50c67cd17a29fd073c16be86f1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
  While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
8
8
  change between releases.
9
9
 
10
+ ## [0.10.0] - 2026-08-31
11
+
12
+ Minor release with no new components. Eight existing ones are reworked — anchored,
13
+ focus, password-reveal, password-strength, scroll-restore, scroll-visibility,
14
+ theme, and transition — and a shared change to the focus trap reaches the six
15
+ modal overlays too. Most of them changed a contract, so read Removed and Changed
16
+ before upgrading. The Inspector manifest stays on schema v12.
17
+
18
+ ### Removed
19
+
20
+ - theme: the `mode` action param. Options declare their mode with
21
+ `data-value="light|dark|system"` — migrate every option or it can never become
22
+ the selection.
23
+ - password-strength: the live region on the `label` target. Drop its `aria-live`,
24
+ seat a `stimeo--announcer` on the page, and set `announceText`.
25
+ - focus: `[autofocus]` initial focus and the optional scroll lock, neither of
26
+ which was ever implemented. Initial focus is the `initial` target, else the
27
+ first focusable descendant, else the container.
28
+
29
+ ### Added
30
+
31
+ - password-strength: `announceText` (`{level}` / `{score}` / `{max}` / `{band}`),
32
+ a `setScore` action for a score computed outside the built-in heuristic, and a
33
+ `reconcile` event.
34
+ - `stimeo check` warns when a page uses password-strength with no
35
+ `stimeo--announcer`.
36
+
37
+ ### Changed
38
+
39
+ - alert-dialog, command-palette, confirm, dialog, drawer, sidebar, and focus:
40
+ background isolation walks from the element up to `<body>` and inerts each
41
+ ancestor's other children, so one nested below `<body>` isolates its own branch.
42
+ - transition: declare a token as a stage Value **or** author it as a standing
43
+ class, not both — a token already on the element is the consumer's and is never
44
+ applied or removed. `connect()` no longer strips half-applied stage classes.
45
+ - A half-applied state is rewound before Turbo caches the page, silently:
46
+ transition's stage classes, focus's `data-focus-trapped`, password-reveal's
47
+ revealed field, and everything password-strength derives from the field value.
48
+ - focus reads its declarations at fixed moments: only `trap` is watched, `auto`
49
+ and `inert` are read when the trap turns on, and `restore` when it turns off.
50
+ - A declaration that cannot be read falls back to its default instead of poisoning
51
+ the widget — anchored's `placement` / `offset` / `padding`, theme's `mode` /
52
+ `target`, scroll-visibility's `offset` / `root` / `focusSelector`,
53
+ password-strength's `minScore` / `levels`.
54
+ - Targets and declarations swapped in at runtime are followed: anchored's `anchor`
55
+ / `floating`, password-reveal's `input` / `toggle`, password-strength's targets,
56
+ theme's options, scroll-visibility's `element` / `offset` / `mode`, and
57
+ scroll-restore's `key` / `axis`.
58
+ - Events report transitions only — theme's `change` when `mode` or `resolved`
59
+ actually moved, scroll-visibility's `change` on a real visibility change
60
+ (connecting is silent), password-strength's announcement when the level changes.
61
+ A scroll with no vertical movement leaves scroll-visibility alone.
62
+ - Smaller contract shifts: password-strength's `data-strength` bands anchor both
63
+ ends of the declared scale (a level count other than four moves the boundaries)
64
+ and its `meter` target needs an accessible name; scroll-restore always restores
65
+ instantly (so `scroll-behavior: smooth` no longer animates it) and writes
66
+ nothing for an element it never restored and the reader never scrolled; theme writes
67
+ `aria-pressed` only on a button and passes modified `Home` / `End` through.
68
+
69
+ ### Fixed
70
+
71
+ - transition: `enter()` from `hidden` runs the CSS transition instead of popping
72
+ in, so `entered` arrives on the real terminal event rather than the safety
73
+ timeout.
74
+ - A control that owns focus is no longer hidden or stranded: scroll-visibility
75
+ holds the hide until it blurs and `toTop` focuses without scrolling, and focus's
76
+ state hook and events follow the controller's own record of trapping.
77
+ - Stored and measured state holds: scroll-restore keeps a saved offset that a
78
+ clamped restore would have overwritten and never republishes it under a new
79
+ `key`; password-reveal arms the `autoHide` it skipped on an already-revealed
80
+ field; anchored measures a target swapped in at runtime and writes no stale
81
+ placement after teardown.
82
+
10
83
  ## [0.9.0] - 2026-08-28
11
84
 
12
85
  Minor release with no new components. Ten existing ones are reworked —
@@ -783,6 +856,7 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
783
856
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
784
857
  RubyGems.
785
858
 
859
+ [0.10.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.10.0
786
860
  [0.9.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.9.0
787
861
  [0.8.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.8.0
788
862
  [0.7.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.7.0
@@ -315,15 +315,25 @@ var FocusTrap = class {
315
315
  * content cannot be focused or reached by assistive technology, honoring the
316
316
  * `aria-modal="true"` contract. An element that was *already* `inert` is left
317
317
  * untracked so `#releaseBackground` does not wrongly clear it.
318
+ *
319
+ * The walk climbs from the container to `body` and inerts each ancestor's other
320
+ * children. Scanning only `body`'s children would skip the branch the container
321
+ * sits in — everything beside it inside that branch is background too, and a
322
+ * nested container is the ordinary case.
318
323
  */
319
324
  #isolateBackground() {
320
325
  const container = this.#getContainer();
321
326
  this.#inertedSiblings = [];
322
- for (const sibling of Array.from(document.body.children)) {
323
- if (!(sibling instanceof HTMLElement)) continue;
324
- if (sibling.contains(container) || sibling.inert) continue;
325
- sibling.inert = true;
326
- this.#inertedSiblings.push(sibling);
327
+ for (let node = container; node !== document.body; ) {
328
+ const parent = node.parentElement;
329
+ if (!parent) break;
330
+ for (const sibling of Array.from(parent.children)) {
331
+ if (!(sibling instanceof HTMLElement)) continue;
332
+ if (sibling === node || sibling.inert) continue;
333
+ sibling.inert = true;
334
+ this.#inertedSiblings.push(sibling);
335
+ }
336
+ node = parent;
327
337
  }
328
338
  }
329
339
  /** Reverts the `inert` flags applied by `#isolateBackground`. */
@@ -361,8 +371,8 @@ var AlertDialogController = class extends Controller {
361
371
  static actions = ["cancel", "confirm", "open"];
362
372
  static events = ["cancel", "confirm"];
363
373
  /**
364
- * Owns the modal side effects. Escape is routed through {@link cancel} so it
365
- * emits the same event as the cancel button (tagged `"escape"`); focus falls
374
+ * Owns the modal side effects. Escape takes the same cancel path as
375
+ * {@link cancel} and emits the same event, tagged `"escape"` instead of `"user"`; focus falls
366
376
  * back to the trigger when nothing was focused before opening.
367
377
  */
368
378
  #trap = new FocusTrap(() => this.dialogTarget, {
@@ -338,7 +338,7 @@ var AnnouncerController = class extends Controller {
338
338
  }
339
339
  /**
340
340
  * Arms `region`'s single pending timer. Callers reach here with the slot
341
- * already free — `#announce` releases it, and a fired timer clears its own
341
+ * already free — `#drain` releases it before writing, and a fired timer clears its own
342
342
  * entry below — so this does not cancel again.
343
343
  */
344
344
  #schedule(region, callback, delay) {
@@ -63,8 +63,8 @@ var BulkSelectController = class extends Controller {
63
63
  static events = ["change", "reconcile"];
64
64
  /** All-pages mode is a transient UI state, mirrored to `data-all-pages` so a
65
65
  * `connect()` over markup that already carries the attribute rehydrates the
66
- * mode — a morph, a Turbo Stream, or a server that renders it back. A restore
67
- * visit serves the server's markup instead, so the mode does not survive one. */
66
+ * mode — a morph, a Turbo Stream, a server that renders it back, or a restore
67
+ * visit, whose cached snapshot carries the attribute too. */
68
68
  #allPagesMode = false;
69
69
  /** Last emitted figures, so a recompute reports only on a real change. */
70
70
  #lastCount = -1;
@@ -217,7 +217,7 @@ var CollapsibleController = class extends Controller {
217
217
  * `open` Value disagrees. An already-open region remains open without a
218
218
  * close/reopen cycle. The Value only seeds a genuinely fresh render where no
219
219
  * state attribute is present yet; any opening animation in that case belongs to
220
- * the consumer's CSS. Mirrors `sidebar`'s `#restoreCollapsed`.
220
+ * the consumer's CSS.
221
221
  */
222
222
  connect() {
223
223
  this.#connected = true;
@@ -380,15 +380,25 @@ var FocusTrap = class {
380
380
  * content cannot be focused or reached by assistive technology, honoring the
381
381
  * `aria-modal="true"` contract. An element that was *already* `inert` is left
382
382
  * untracked so `#releaseBackground` does not wrongly clear it.
383
+ *
384
+ * The walk climbs from the container to `body` and inerts each ancestor's other
385
+ * children. Scanning only `body`'s children would skip the branch the container
386
+ * sits in — everything beside it inside that branch is background too, and a
387
+ * nested container is the ordinary case.
383
388
  */
384
389
  #isolateBackground() {
385
390
  const container = this.#getContainer();
386
391
  this.#inertedSiblings = [];
387
- for (const sibling of Array.from(document.body.children)) {
388
- if (!(sibling instanceof HTMLElement)) continue;
389
- if (sibling.contains(container) || sibling.inert) continue;
390
- sibling.inert = true;
391
- this.#inertedSiblings.push(sibling);
392
+ for (let node = container; node !== document.body; ) {
393
+ const parent = node.parentElement;
394
+ if (!parent) break;
395
+ for (const sibling of Array.from(parent.children)) {
396
+ if (!(sibling instanceof HTMLElement)) continue;
397
+ if (sibling === node || sibling.inert) continue;
398
+ sibling.inert = true;
399
+ this.#inertedSiblings.push(sibling);
400
+ }
401
+ node = parent;
392
402
  }
393
403
  }
394
404
  /** Reverts the `inert` flags applied by `#isolateBackground`. */
@@ -315,15 +315,25 @@ var FocusTrap = class {
315
315
  * content cannot be focused or reached by assistive technology, honoring the
316
316
  * `aria-modal="true"` contract. An element that was *already* `inert` is left
317
317
  * untracked so `#releaseBackground` does not wrongly clear it.
318
+ *
319
+ * The walk climbs from the container to `body` and inerts each ancestor's other
320
+ * children. Scanning only `body`'s children would skip the branch the container
321
+ * sits in — everything beside it inside that branch is background too, and a
322
+ * nested container is the ordinary case.
318
323
  */
319
324
  #isolateBackground() {
320
325
  const container = this.#getContainer();
321
326
  this.#inertedSiblings = [];
322
- for (const sibling of Array.from(document.body.children)) {
323
- if (!(sibling instanceof HTMLElement)) continue;
324
- if (sibling.contains(container) || sibling.inert) continue;
325
- sibling.inert = true;
326
- this.#inertedSiblings.push(sibling);
327
+ for (let node = container; node !== document.body; ) {
328
+ const parent = node.parentElement;
329
+ if (!parent) break;
330
+ for (const sibling of Array.from(parent.children)) {
331
+ if (!(sibling instanceof HTMLElement)) continue;
332
+ if (sibling === node || sibling.inert) continue;
333
+ sibling.inert = true;
334
+ this.#inertedSiblings.push(sibling);
335
+ }
336
+ node = parent;
327
337
  }
328
338
  }
329
339
  /** Reverts the `inert` flags applied by `#isolateBackground`. */
@@ -121,7 +121,7 @@ var CountdownController = class extends Controller {
121
121
  #pausedAmount = 0;
122
122
  /**
123
123
  * The amount the slots are currently showing, floored to the second they render.
124
- * It lags {@link currentAmount} by up to one tick, and it — not the live reading —
124
+ * It lags {@link #currentAmount} by up to one tick, and it — not the live reading —
125
125
  * is what a pause has to preserve: storing the fraction behind the display instead
126
126
  * makes the first tick after a resume step by two units.
127
127
  */
@@ -298,7 +298,7 @@ var DataGridController = class extends Controller {
298
298
  const row = cell.closest("[role='row']");
299
299
  if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
300
300
  }
301
- /** Shared sort logic for both click and keyboard activation. */
301
+ /** Cycles a header's sort on keyboard activation and emits `sort`. */
302
302
  #cycleSort(header) {
303
303
  const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
304
304
  for (const other of this.columnHeaderTargets) {
@@ -315,15 +315,25 @@ var FocusTrap = class {
315
315
  * content cannot be focused or reached by assistive technology, honoring the
316
316
  * `aria-modal="true"` contract. An element that was *already* `inert` is left
317
317
  * untracked so `#releaseBackground` does not wrongly clear it.
318
+ *
319
+ * The walk climbs from the container to `body` and inerts each ancestor's other
320
+ * children. Scanning only `body`'s children would skip the branch the container
321
+ * sits in — everything beside it inside that branch is background too, and a
322
+ * nested container is the ordinary case.
318
323
  */
319
324
  #isolateBackground() {
320
325
  const container = this.#getContainer();
321
326
  this.#inertedSiblings = [];
322
- for (const sibling of Array.from(document.body.children)) {
323
- if (!(sibling instanceof HTMLElement)) continue;
324
- if (sibling.contains(container) || sibling.inert) continue;
325
- sibling.inert = true;
326
- this.#inertedSiblings.push(sibling);
327
+ for (let node = container; node !== document.body; ) {
328
+ const parent = node.parentElement;
329
+ if (!parent) break;
330
+ for (const sibling of Array.from(parent.children)) {
331
+ if (!(sibling instanceof HTMLElement)) continue;
332
+ if (sibling === node || sibling.inert) continue;
333
+ sibling.inert = true;
334
+ this.#inertedSiblings.push(sibling);
335
+ }
336
+ node = parent;
327
337
  }
328
338
  }
329
339
  /** Reverts the `inert` flags applied by `#isolateBackground`. */
@@ -310,10 +310,6 @@ var DirectUploadController = class extends Controller {
310
310
  this.#rows.delete(id);
311
311
  }
312
312
  }
313
- /**
314
- * Returns the widget to its pre-upload state just before Turbo caches the
315
- * page, so the snapshot never replays rows for uploads that cannot resume.
316
- */
317
313
  /**
318
314
  * Rewinds for the snapshot and reports what that discarded. An upload in flight
319
315
  * cannot survive the navigation, so a consumer mirroring the rows would keep a
@@ -352,7 +348,7 @@ var DirectUploadController = class extends Controller {
352
348
  #detail(event) {
353
349
  return event.detail ?? {};
354
350
  }
355
- /** The file name every ActiveStorage `direct-upload:*` event carries. */
351
+ /** The event's file name, or `""` when it carries none. */
356
352
  #name(detail) {
357
353
  return detail.file?.name ?? "";
358
354
  }
@@ -315,15 +315,25 @@ var FocusTrap = class {
315
315
  * content cannot be focused or reached by assistive technology, honoring the
316
316
  * `aria-modal="true"` contract. An element that was *already* `inert` is left
317
317
  * untracked so `#releaseBackground` does not wrongly clear it.
318
+ *
319
+ * The walk climbs from the container to `body` and inerts each ancestor's other
320
+ * children. Scanning only `body`'s children would skip the branch the container
321
+ * sits in — everything beside it inside that branch is background too, and a
322
+ * nested container is the ordinary case.
318
323
  */
319
324
  #isolateBackground() {
320
325
  const container = this.#getContainer();
321
326
  this.#inertedSiblings = [];
322
- for (const sibling of Array.from(document.body.children)) {
323
- if (!(sibling instanceof HTMLElement)) continue;
324
- if (sibling.contains(container) || sibling.inert) continue;
325
- sibling.inert = true;
326
- this.#inertedSiblings.push(sibling);
327
+ for (let node = container; node !== document.body; ) {
328
+ const parent = node.parentElement;
329
+ if (!parent) break;
330
+ for (const sibling of Array.from(parent.children)) {
331
+ if (!(sibling instanceof HTMLElement)) continue;
332
+ if (sibling === node || sibling.inert) continue;
333
+ sibling.inert = true;
334
+ this.#inertedSiblings.push(sibling);
335
+ }
336
+ node = parent;
327
337
  }
328
338
  }
329
339
  /** Reverts the `inert` flags applied by `#isolateBackground`. */
@@ -576,8 +586,8 @@ var DrawerController = class extends Controller {
576
586
  * rather than being re-derived from the declarative `open` Value (which would
577
587
  * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
578
588
  * render. We normalize to a clean closed baseline first so {@link open} runs its
579
- * full reveal + trap activation — the {@link FocusTrap} is a fresh instance
580
- * after a reconnect and must be re-activated.
589
+ * full reveal + trap activation — the {@link FocusTrap} is inactive after a
590
+ * disconnect and must be re-activated.
581
591
  */
582
592
  connect() {
583
593
  this.#connected = true;
@@ -315,15 +315,25 @@ var FocusTrap = class {
315
315
  * content cannot be focused or reached by assistive technology, honoring the
316
316
  * `aria-modal="true"` contract. An element that was *already* `inert` is left
317
317
  * untracked so `#releaseBackground` does not wrongly clear it.
318
+ *
319
+ * The walk climbs from the container to `body` and inerts each ancestor's other
320
+ * children. Scanning only `body`'s children would skip the branch the container
321
+ * sits in — everything beside it inside that branch is background too, and a
322
+ * nested container is the ordinary case.
318
323
  */
319
324
  #isolateBackground() {
320
325
  const container = this.#getContainer();
321
326
  this.#inertedSiblings = [];
322
- for (const sibling of Array.from(document.body.children)) {
323
- if (!(sibling instanceof HTMLElement)) continue;
324
- if (sibling.contains(container) || sibling.inert) continue;
325
- sibling.inert = true;
326
- this.#inertedSiblings.push(sibling);
327
+ for (let node = container; node !== document.body; ) {
328
+ const parent = node.parentElement;
329
+ if (!parent) break;
330
+ for (const sibling of Array.from(parent.children)) {
331
+ if (!(sibling instanceof HTMLElement)) continue;
332
+ if (sibling === node || sibling.inert) continue;
333
+ sibling.inert = true;
334
+ this.#inertedSiblings.push(sibling);
335
+ }
336
+ node = parent;
327
337
  }
328
338
  }
329
339
  /** Reverts the `inert` flags applied by `#isolateBackground`. */
@@ -375,13 +385,39 @@ var FocusController = class extends Controller {
375
385
  initialFocus: () => this.hasInitialTarget ? this.initialTarget : null,
376
386
  onEscape: () => this.deactivate()
377
387
  });
388
+ /**
389
+ * Whether this scope is currently trapping.
390
+ *
391
+ * Held here rather than read back from the trap: the trap releases itself
392
+ * before Turbo caches the page, so its own flag stops answering for the state
393
+ * this controller publishes on the element and in its events.
394
+ */
395
+ #active = false;
396
+ /**
397
+ * Returns the element to its untrapped form before Turbo copies the page. The
398
+ * trap performs its own release on the same event, so what is left is the hook
399
+ * this controller owns — carried into the snapshot it would describe a scope
400
+ * that is no longer trapping.
401
+ *
402
+ * Silent, like `disconnect()`: the page is about to be frozen, and a release
403
+ * nobody asked for is not a close to report.
404
+ */
405
+ #beforeCache = new BeforeCacheReset(() => {
406
+ this.#active = false;
407
+ this.element.removeAttribute("data-focus-trapped");
408
+ });
378
409
  /** Stimulus drives activation from the `trap` value (also fires on connect). */
379
410
  trapValueChanged() {
380
411
  if (this.trapValue) this.#activate();
381
412
  else this.#deactivate();
382
413
  }
414
+ connect() {
415
+ this.#beforeCache.activate();
416
+ }
383
417
  disconnect() {
384
418
  this.#trap.deactivate({ restoreFocus: false });
419
+ this.#beforeCache.deactivate();
420
+ this.#active = false;
385
421
  this.element.removeAttribute("data-focus-trapped");
386
422
  }
387
423
  /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */
@@ -395,13 +431,15 @@ var FocusController = class extends Controller {
395
431
  this.#deactivate();
396
432
  }
397
433
  #activate() {
398
- if (this.#trap.active) return;
434
+ if (this.#active) return;
435
+ this.#active = true;
399
436
  this.#trap.activate();
400
437
  this.element.setAttribute("data-focus-trapped", "true");
401
438
  this.dispatch("activate", { detail: {} });
402
439
  }
403
440
  #deactivate() {
404
- if (!this.#trap.active) return;
441
+ if (!this.#active) return;
442
+ this.#active = false;
405
443
  this.#trap.deactivate({ restoreFocus: this.restoreValue });
406
444
  this.element.removeAttribute("data-focus-trapped");
407
445
  this.dispatch("deactivate", { detail: {} });
@@ -146,7 +146,7 @@ var FormFieldController = class _FormFieldController extends Controller {
146
146
  static #INVALID_ATTR = "data-stimeo--form-field-invalid";
147
147
  /** Collapses one target/morph batch into one silent ARIA reconciliation. */
148
148
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
149
- /** ARIA ownership is scoped to the current singular control target. */
149
+ /** Returns borrowed control ARIA before Turbo snapshots the page. */
150
150
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
151
151
  #ariaDescribedBy = new AttributeLease("aria-describedby");
152
152
  #ariaErrorMessage = new AttributeLease("aria-errormessage");
@@ -629,8 +629,8 @@ var MenubarController = class extends Controller {
629
629
  * being hidden itself, a menu can stay visible after its owning top was removed,
630
630
  * an open pair can appear from a morph with no layer registered for it, and the
631
631
  * Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
632
- * all. Nothing is remembered between calls (except which side of the Escape stack
633
- * this layer is on, which the stack itself does not expose), so the outcome is the
632
+ * all. The only state carried between calls is the tracked focus record and which side
633
+ * of the Escape stack this layer is on (which the stack itself does not expose), so the outcome is the
634
634
  * same whichever mutation arrived and calling it more often than needed is free.
635
635
  */
636
636
  #reconcile() {
@@ -1048,7 +1048,7 @@ var MultiSelectController = class extends Controller {
1048
1048
  get #values() {
1049
1049
  return this.#selectedOptions.map((option) => this.#optionValue(option));
1050
1050
  }
1051
- /** Normalized cardinality cap: zero is unlimited and positive fractions round down. */
1051
+ /** Normalized cardinality cap: zero and below are unlimited; a positive value floors, never below 1. */
1052
1052
  get #selectionLimit() {
1053
1053
  if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
1054
1054
  return Math.max(1, Math.floor(this.maxValue));
@@ -382,8 +382,8 @@ var OverflowMenuController = class extends Controller {
382
382
  *
383
383
  * With no known child at all (a fresh instance connecting to markup that already
384
384
  * holds banked items), a fully-banked snapshot's inert boundary preserves whether an
385
- * unindexed run was prepended or appended before connect. Older or server-rendered
386
- * markup has no boundary; for that compatibility path the saved index is used as the
385
+ * unindexed run was prepended or appended before connect. Markup that holds banked items but no boundary —
386
+ * server-rendered or hand-authored uses the saved index as the
387
387
  * offset, the inverse of the move that banked it.
388
388
  */
389
389
  #merge(bar, banked, boundaryAt) {
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/password_reveal_controller.ts
4
4
 
5
+ // src/utils/before_cache_reset.ts
6
+ var BeforeCacheReset = class _BeforeCacheReset {
7
+ /** Every subscribed instance, iterated by the one shared document listener. */
8
+ static #subscribers = /* @__PURE__ */ new Set();
9
+ /** The shared listener; installed while at least one instance is subscribed. */
10
+ static #onBeforeCache = () => {
11
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
12
+ };
13
+ #rewind;
14
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
15
+ constructor(rewind) {
16
+ this.#rewind = rewind;
17
+ }
18
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
19
+ activate() {
20
+ const first = _BeforeCacheReset.#subscribers.size === 0;
21
+ _BeforeCacheReset.#subscribers.add(this);
22
+ if (first) {
23
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
24
+ }
25
+ }
26
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
27
+ deactivate() {
28
+ _BeforeCacheReset.#subscribers.delete(this);
29
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
30
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
31
+ }
32
+ };
33
+
5
34
  // src/utils/safe_timeout.ts
6
35
  var TimerRegistry = class {
7
36
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -56,6 +85,7 @@ var SafeTimeout = class extends TimerRegistry {
56
85
  };
57
86
 
58
87
  // src/controllers/password_reveal_controller.ts
88
+ var MAX_DELAY = 2 ** 31 - 1;
59
89
  var PasswordRevealController = class extends Controller {
60
90
  static targets = ["input", "toggle"];
61
91
  static values = {
@@ -65,11 +95,54 @@ var PasswordRevealController = class extends Controller {
65
95
  static events = ["toggle"];
66
96
  /** Auto re-mask timer; torn down on disconnect. */
67
97
  #timers = new SafeTimeout();
98
+ /**
99
+ * Whether this controller is between `connect()` and `disconnect()`.
100
+ *
101
+ * Target callbacks outlive the controller: Stimulus stops the target observer
102
+ * after `disconnect()`, so a field leaving after teardown still reaches
103
+ * {@link PasswordRevealController.inputTargetDisconnected}. Arming from there
104
+ * would put a timer back that nothing will clear. The element staying in the
105
+ * document does not answer this — unloading the controller leaves it there.
106
+ */
107
+ #connected = false;
108
+ /** Masks the field before Turbo copies the page into its snapshot. */
109
+ #beforeCache = new BeforeCacheReset(() => this.#rewindToMasked());
68
110
  connect() {
69
- this.#reflect(this.#isVisible);
111
+ this.#connected = true;
112
+ const visible = this.#isVisible;
113
+ this.#reflect(visible);
114
+ this.#beforeCache.activate();
115
+ this.#arm(visible);
70
116
  }
71
117
  disconnect() {
118
+ this.#connected = false;
72
119
  this.#timers.clearAll();
120
+ this.#beforeCache.deactivate();
121
+ }
122
+ /** Re-derives the hooks and the re-mask for a field swapped in after connect. */
123
+ inputTargetConnected() {
124
+ const visible = this.#connected && this.#isVisible;
125
+ this.#reflect(visible);
126
+ this.#arm(visible);
127
+ }
128
+ /**
129
+ * Re-derives from whatever field is left rather than assuming none is. A swap
130
+ * delivers this callback next to the arrival in either order, so a revealed
131
+ * replacement that answered "masked" here would be described as hidden while
132
+ * showing the password, and would carry no re-mask.
133
+ *
134
+ * Teardown is the one case with nothing to derive: target callbacks run after
135
+ * `disconnect()`, so the field is still revealed and re-arming from it would
136
+ * outlive the controller. A detached root is the signal to stand down.
137
+ */
138
+ inputTargetDisconnected() {
139
+ const visible = this.#connected && this.element.isConnected && this.#isVisible;
140
+ this.#reflect(visible);
141
+ this.#arm(visible);
142
+ }
143
+ /** Re-derives the pressed state for a button swapped in after connect. */
144
+ toggleTargetConnected() {
145
+ this.#reflect(this.#isVisible);
73
146
  }
74
147
  /** Toggles the input between masked and revealed. Bound via `data-action`. */
75
148
  toggle() {
@@ -98,11 +171,37 @@ var PasswordRevealController = class extends Controller {
98
171
  }
99
172
  this.#reflect(visible);
100
173
  this.dispatch("toggle", { detail: { visible } });
174
+ this.#arm(visible);
175
+ }
176
+ /** Schedules the auto re-mask for a revealed field, replacing any pending one. */
177
+ #arm(visible) {
101
178
  this.#timers.clearAll();
102
- if (visible && this.autoHideValue > 0) {
103
- this.#timers.set(() => this.#setVisible(false), this.autoHideValue);
179
+ const delay = this.#autoHideDelay;
180
+ if (visible && delay > 0) {
181
+ this.#timers.set(() => this.#setVisible(false), delay);
104
182
  }
105
183
  }
184
+ /**
185
+ * The auto re-mask delay, held to what `setTimeout` can carry. Past that limit
186
+ * a delay folds to zero, turning "keep it showing" into "hide it at once" —
187
+ * the opposite of what the declaration asked for. A value that is no delay at
188
+ * all stays out of the positive range {@link PasswordRevealController.#arm}
189
+ * requires, so it schedules nothing.
190
+ */
191
+ get #autoHideDelay() {
192
+ return Math.min(this.autoHideValue, MAX_DELAY);
193
+ }
194
+ /**
195
+ * Returns the field to masked before Turbo copies the page. Silent and
196
+ * focus-free: the page is about to be frozen, so there is no one to tell and
197
+ * nowhere for focus to go.
198
+ */
199
+ #rewindToMasked() {
200
+ this.#timers.clearAll();
201
+ if (!this.hasInputTarget) return;
202
+ this.inputTarget.type = "password";
203
+ this.#reflect(false);
204
+ }
106
205
  /** Reflects the visible state onto `aria-pressed` and `data-state`. */
107
206
  #reflect(visible) {
108
207
  if (this.hasToggleTarget) {