stimeo-ui 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/dist/cable/index.js +226 -43
  4. data/dist/controllers/alert_dialog_controller.js +17 -7
  5. data/dist/controllers/announcer_controller.js +1 -1
  6. data/dist/controllers/bulk_select_controller.js +2 -2
  7. data/dist/controllers/collapsible_controller.js +1 -1
  8. data/dist/controllers/command_palette_controller.js +15 -5
  9. data/dist/controllers/confirm_controller.js +15 -5
  10. data/dist/controllers/countdown_controller.js +1 -1
  11. data/dist/controllers/data_grid_controller.js +1 -1
  12. data/dist/controllers/dialog_controller.js +15 -5
  13. data/dist/controllers/direct_upload_controller.js +1 -5
  14. data/dist/controllers/drawer_controller.js +17 -7
  15. data/dist/controllers/focus_controller.js +45 -7
  16. data/dist/controllers/form_field_controller.js +1 -1
  17. data/dist/controllers/menubar_controller.js +2 -2
  18. data/dist/controllers/multi_select_controller.js +1 -1
  19. data/dist/controllers/overflow_menu_controller.js +2 -2
  20. data/dist/controllers/password_reveal_controller.js +102 -3
  21. data/dist/controllers/password_strength_controller.js +287 -42
  22. data/dist/controllers/pointer_drag_controller.js +64 -18
  23. data/dist/controllers/portal_controller.js +32 -11
  24. data/dist/controllers/preview_guard_controller.js +180 -20
  25. data/dist/controllers/resizable_controller.js +1 -1
  26. data/dist/controllers/roving_controller.js +127 -14
  27. data/dist/controllers/scroll_restore_controller.js +127 -27
  28. data/dist/controllers/scroll_visibility_controller.js +167 -14
  29. data/dist/controllers/sidebar_controller.js +15 -5
  30. data/dist/controllers/step_indicator_controller.js +0 -5
  31. data/dist/controllers/stick_to_bottom_controller.js +149 -28
  32. data/dist/controllers/theme_controller.js +151 -40
  33. data/dist/controllers/toast_controller.js +2 -2
  34. data/dist/controllers/transition_controller.js +79 -15
  35. data/dist/index.js +1188 -318
  36. data/dist/positioning/index.js +84 -25
  37. data/lib/stimeo/ui/version.rb +1 -1
  38. metadata +2 -2
@@ -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) {