stimeo-ui 0.3.0 → 0.5.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +124 -0
  3. data/dist/controllers/alert_dialog_controller.js +32 -5
  4. data/dist/controllers/announcer_controller.js +255 -20
  5. data/dist/controllers/aspect_ratio_controller.js +1 -1
  6. data/dist/controllers/breadcrumb_controller.js +5 -1
  7. data/dist/controllers/carousel_controller.js +5 -1
  8. data/dist/controllers/clipboard_controller.js +8 -3
  9. data/dist/controllers/collapsible_controller.js +4 -1
  10. data/dist/controllers/color_picker_controller.js +52 -2
  11. data/dist/controllers/command_palette_controller.js +32 -5
  12. data/dist/controllers/confirm_controller.js +32 -5
  13. data/dist/controllers/context_menu_controller.js +2 -2
  14. data/dist/controllers/countdown_controller.js +117 -13
  15. data/dist/controllers/date_range_picker_controller.js +55 -1
  16. data/dist/controllers/dialog_controller.js +32 -5
  17. data/dist/controllers/direct_upload_controller.js +3 -3
  18. data/dist/controllers/drawer_controller.js +32 -5
  19. data/dist/controllers/empty_state_controller.js +128 -23
  20. data/dist/controllers/flash_controller.js +161 -21
  21. data/dist/controllers/focus_controller.js +32 -5
  22. data/dist/controllers/form_validation_controller.js +8 -2
  23. data/dist/controllers/frame_loading_controller.js +261 -27
  24. data/dist/controllers/highlight_controller.js +38 -1
  25. data/dist/controllers/idle_controller.js +13 -2
  26. data/dist/controllers/local_time_controller.js +102 -6
  27. data/dist/controllers/masonry_controller.js +1 -1
  28. data/dist/controllers/meter_controller.js +147 -26
  29. data/dist/controllers/network_status_controller.js +29 -11
  30. data/dist/controllers/number_input_controller.js +191 -24
  31. data/dist/controllers/overflow_menu_controller.js +34 -7
  32. data/dist/controllers/pagination_controller.js +5 -1
  33. data/dist/controllers/password_strength_controller.js +1 -1
  34. data/dist/controllers/pointer_drag_controller.js +10 -0
  35. data/dist/controllers/portal_controller.js +10 -0
  36. data/dist/controllers/progress_controller.js +123 -12
  37. data/dist/controllers/range_slider_controller.js +449 -93
  38. data/dist/controllers/rating_controller.js +55 -0
  39. data/dist/controllers/relative_time_controller.js +135 -12
  40. data/dist/controllers/scroll_area_controller.js +1 -1
  41. data/dist/controllers/separator_controller.js +13 -17
  42. data/dist/controllers/sidebar_controller.js +37 -8
  43. data/dist/controllers/skeleton_controller.js +143 -22
  44. data/dist/controllers/slider_controller.js +342 -50
  45. data/dist/controllers/spinner_controller.js +244 -28
  46. data/dist/controllers/step_indicator_controller.js +85 -6
  47. data/dist/controllers/stepper_controller.js +2 -0
  48. data/dist/controllers/stick_to_bottom_controller.js +60 -10
  49. data/dist/controllers/switch_controller.js +162 -18
  50. data/dist/controllers/textarea_autosize_controller.js +1 -1
  51. data/dist/controllers/time_picker_controller.js +6 -3
  52. data/dist/controllers/tree_view_controller.js +19 -1
  53. data/dist/index.js +2278 -596
  54. data/lib/stimeo/ui/version.rb +1 -1
  55. metadata +2 -2
@@ -13,6 +13,39 @@ function isReservedArrowChord(event, allow = []) {
13
13
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
14
  }
15
15
 
16
+ // src/utils/microtask_coalescer.ts
17
+ var MicrotaskCoalescer = class {
18
+ #run;
19
+ #queued = false;
20
+ #active = false;
21
+ #generation = 0;
22
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
23
+ constructor(run) {
24
+ this.#run = run;
25
+ }
26
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
27
+ activate() {
28
+ this.#active = true;
29
+ }
30
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
31
+ cancel() {
32
+ this.#active = false;
33
+ this.#queued = false;
34
+ this.#generation += 1;
35
+ }
36
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
37
+ schedule() {
38
+ if (!this.#active || this.#queued) return;
39
+ this.#queued = true;
40
+ const generation = this.#generation;
41
+ queueMicrotask(() => {
42
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
43
+ this.#queued = false;
44
+ this.#run();
45
+ });
46
+ }
47
+ };
48
+
16
49
  // src/utils/roving_tabindex.ts
17
50
  var RovingTabindex = class {
18
51
  /** Returns the current ordered item elements; called on every operation. */
@@ -59,13 +92,33 @@ var RatingController = class extends Controller {
59
92
  static events = ["change"];
60
93
  #roving = new RovingTabindex(() => this.symbolTargets);
61
94
  /** Reflects the initial value, or switches to the non-interactive readonly view. */
95
+ /**
96
+ * Collapses a morph that swaps render inputs into one repaint, and refuses the
97
+ * pass Stimulus delivers before `connect()`.
98
+ */
99
+ #repaint = new MicrotaskCoalescer(() => {
100
+ if (this.readonlyValue) {
101
+ this.#applyReadonly();
102
+ return;
103
+ }
104
+ this.#apply(this.#clamp(this.valueValue), { focus: false });
105
+ });
62
106
  connect() {
107
+ this.#repaint.activate();
63
108
  if (this.readonlyValue) {
64
109
  this.#applyReadonly();
65
110
  return;
66
111
  }
67
112
  this.#apply(this.#clamp(this.valueValue), { focus: false });
68
113
  }
114
+ /** Closes the window in which a queued repaint may still run. */
115
+ disconnect() {
116
+ this.#repaint.cancel();
117
+ }
118
+ /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
119
+ valueValueChanged() {
120
+ this.#repaint.schedule();
121
+ }
69
122
  /** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */
70
123
  select(event) {
71
124
  if (this.readonlyValue) return;
@@ -122,6 +175,8 @@ var RatingController = class extends Controller {
122
175
  * Applies `value` (already clamped) everywhere, then dispatches `change`.
123
176
  * Use for user-driven changes; on connect call `#apply` directly so
124
177
  * initialization mirrors state without emitting an event.
178
+ *
179
+ * @stimeoRenderRoot
125
180
  */
126
181
  #render(value, { focus }) {
127
182
  this.#apply(value, { focus });
@@ -2,6 +2,68 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/relative_time_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
+
34
+ // src/utils/microtask_coalescer.ts
35
+ var MicrotaskCoalescer = class {
36
+ #run;
37
+ #queued = false;
38
+ #active = false;
39
+ #generation = 0;
40
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
41
+ constructor(run) {
42
+ this.#run = run;
43
+ }
44
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
45
+ activate() {
46
+ this.#active = true;
47
+ }
48
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
49
+ cancel() {
50
+ this.#active = false;
51
+ this.#queued = false;
52
+ this.#generation += 1;
53
+ }
54
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
55
+ schedule() {
56
+ if (!this.#active || this.#queued) return;
57
+ this.#queued = true;
58
+ const generation = this.#generation;
59
+ queueMicrotask(() => {
60
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
61
+ this.#queued = false;
62
+ this.#run();
63
+ });
64
+ }
65
+ };
66
+
5
67
  // src/utils/safe_timeout.ts
6
68
  var TimerRegistry = class {
7
69
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -73,11 +135,16 @@ var RelativeTimeController = class extends Controller {
73
135
  tickInterval: { type: Number, default: 6e4 }
74
136
  };
75
137
  #timers = new SafeTimeout();
138
+ /** Collapses a morph that swaps several render inputs at once into one repaint. */
139
+ #resync = new MicrotaskCoalescer(() => this.#resyncToValues());
140
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
76
141
  /** Epoch ms parsed from `datetime`; `NaN` when absent or invalid. */
77
142
  #targetMs = Number.NaN;
78
143
  /** The authored absolute text, restored when the threshold fallback kicks in. */
79
144
  #absoluteText = "";
80
145
  connect() {
146
+ this.#resync.activate();
147
+ this.#beforeCache.activate();
81
148
  if (this.element.getAttribute("data-state") !== "relative") {
82
149
  this.#absoluteText = (this.element.textContent ?? "").trim();
83
150
  }
@@ -86,9 +153,50 @@ var RelativeTimeController = class extends Controller {
86
153
  this.#schedule();
87
154
  }
88
155
  disconnect() {
156
+ this.#resync.cancel();
157
+ this.#beforeCache.deactivate();
89
158
  this.#timers.clearAll();
90
159
  }
91
- /** Renders the current representation and reschedules unless it is now absolute. */
160
+ /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
161
+ localeValueChanged() {
162
+ this.#resync.schedule();
163
+ }
164
+ /** Repaints when application code (or a Turbo morph) changes `threshold` at runtime. */
165
+ thresholdValueChanged() {
166
+ this.#resync.schedule();
167
+ }
168
+ /** Repaints when application code (or a Turbo morph) changes `tickInterval` at runtime. */
169
+ tickIntervalValueChanged() {
170
+ this.#resync.schedule();
171
+ }
172
+ /**
173
+ * Renders against the current Values and re-arms the poll from now.
174
+ *
175
+ * Render only: it emits no event, and clearing first keeps the single self-arming
176
+ * timer single — scheduling on top of a pending one would double the poll rate for
177
+ * the rest of the session. A stamp whose `datetime` never parsed has nothing to
178
+ * render, and one that already reached its terminal fallback simply renders it
179
+ * again and stops.
180
+ */
181
+ #resyncToValues() {
182
+ if (Number.isNaN(this.#targetMs)) return;
183
+ this.#timers.clearAll();
184
+ this.#schedule();
185
+ }
186
+ /**
187
+ * Restores the authored absolute text and the pre-render state for the snapshot
188
+ * Turbo is about to take, leaving the live page's poll timer alone.
189
+ *
190
+ * With no authored text held there is nothing to restore, and `data-state` has to
191
+ * stay as it is: that marker is what tells the next `connect()` the visible text is
192
+ * a rendered relative form rather than an absolute fallback to hold on to.
193
+ */
194
+ #rewindForCache() {
195
+ if (!this.#absoluteText) return;
196
+ this.element.textContent = this.#absoluteText;
197
+ this.element.removeAttribute("data-state");
198
+ }
199
+ /** Renders the current representation and reschedules unless polling can stop. */
92
200
  #schedule() {
93
201
  const nextDelay = this.#applyAndComputeDelay();
94
202
  if (nextDelay !== null) {
@@ -96,31 +204,46 @@ var RelativeTimeController = class extends Controller {
96
204
  }
97
205
  }
98
206
  /**
99
- * Updates the visible text and returns the next poll delay (ms), or `null`
100
- * once the absolute fallback is shown (which never changes, so stop polling).
207
+ * Updates the visible text and returns the next poll delay (ms), or `null` when
208
+ * polling can stop: a *past* timestamp that fell back to the absolute text can
209
+ * never leave it again, and a locale the runtime rejects has nothing to render
210
+ * until that value is corrected.
211
+ *
212
+ * @stimeoRenderRoot
101
213
  */
102
214
  #applyAndComputeDelay() {
103
215
  const deltaMs = this.#targetMs - Date.now();
104
216
  const absSeconds = Math.abs(deltaMs) / 1e3;
217
+ const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
218
+ const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
219
+ const nextDelay = Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
105
220
  if (this.thresholdValue > 0 && absSeconds >= this.thresholdValue && this.#absoluteText) {
106
221
  this.element.textContent = this.#absoluteText;
107
222
  this.element.setAttribute("data-state", "absolute");
108
- return null;
223
+ if (deltaMs <= 0) return null;
224
+ return Math.min(nextDelay, deltaMs - this.thresholdValue * 1e3 + 1);
109
225
  }
110
- const scale = UNITS.find((u) => absSeconds < u.limit) ?? YEAR_SCALE;
226
+ const formatter = this.#formatter;
227
+ if (formatter === null) return null;
111
228
  const value = Math.round(deltaMs / scale.ms);
112
- this.element.textContent = this.#formatter.format(value, scale.unit);
229
+ this.element.textContent = formatter.format(value, scale.unit);
113
230
  this.element.setAttribute("data-state", "relative");
114
- const unitFloor = scale.unit === "second" || scale.unit === "minute" ? 6e4 : scale.ms;
115
- return Math.max(this.tickIntervalValue, Math.min(unitFloor, 864e5));
231
+ return nextDelay;
116
232
  }
117
- /** A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`). */
233
+ /**
234
+ * A `RelativeTimeFormat` for the resolved locale (`numeric: "auto"`), or `null`
235
+ * when the runtime rejects that locale.
236
+ */
118
237
  get #formatter() {
119
- return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
238
+ try {
239
+ return new Intl.RelativeTimeFormat(this.#locale, { numeric: "auto" });
240
+ } catch {
241
+ return null;
242
+ }
120
243
  }
121
- /** Locale precedence: the value, then the element's `lang`, then the document's. */
244
+ /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
122
245
  get #locale() {
123
- return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
246
+ return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
124
247
  }
125
248
  };
126
249
 
@@ -165,7 +165,7 @@ var ScrollAreaController = class extends Controller {
165
165
  this.#syncKeyboardReach(vp, overflowing);
166
166
  const { position, progress } = this.#measurePosition(vp);
167
167
  this.element.setAttribute("data-scroll", position);
168
- this.element.style.setProperty("--stimeo-scroll-progress", String(progress));
168
+ this.element.style.setProperty("--stimeo--scroll-progress", String(progress));
169
169
  const edge = position === "start" ? "start" : position === "end" ? "end" : null;
170
170
  if (overflowing && edge && edge !== this.#lastEdge) {
171
171
  this.#lastEdge = edge;
@@ -8,6 +8,13 @@ function isReservedArrowChord(event, allow = []) {
8
8
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
9
  }
10
10
 
11
+ // src/utils/default_attribute.ts
12
+ function setDefaultAttribute(element, name, value) {
13
+ if (element.hasAttribute(name)) return false;
14
+ element.setAttribute(name, value);
15
+ return true;
16
+ }
17
+
11
18
  // src/controllers/separator_controller.ts
12
19
  var SeparatorController = class extends Controller {
13
20
  static values = {
@@ -18,19 +25,13 @@ var SeparatorController = class extends Controller {
18
25
  static actions = ["onKeydown"];
19
26
  static events = ["change"];
20
27
  connect() {
21
- if (!this.element.hasAttribute("role")) {
22
- this.element.setAttribute("role", "separator");
23
- }
24
- if (!this.element.hasAttribute("aria-orientation")) {
25
- this.element.setAttribute("aria-orientation", this.orientationValue);
26
- }
28
+ setDefaultAttribute(this.element, "role", "separator");
29
+ setDefaultAttribute(this.element, "aria-orientation", this.orientationValue);
27
30
  if (this.focusableValue) {
28
- if (!this.element.hasAttribute("tabindex")) {
29
- this.element.setAttribute("tabindex", "0");
30
- }
31
- this.#setDefault("aria-valuemin", "0");
32
- this.#setDefault("aria-valuemax", "100");
33
- this.#setDefault("aria-valuenow", String(this.#clamp(this.#value)));
31
+ setDefaultAttribute(this.element, "tabindex", "0");
32
+ setDefaultAttribute(this.element, "aria-valuemin", "0");
33
+ setDefaultAttribute(this.element, "aria-valuemax", "100");
34
+ setDefaultAttribute(this.element, "aria-valuenow", String(this.#clamp(this.#value)));
34
35
  }
35
36
  }
36
37
  /** Adjusts the value on arrow / Home / End keys (focusable variant only). */
@@ -84,11 +85,6 @@ var SeparatorController = class extends Controller {
84
85
  const parsed = Number.parseFloat(this.element.getAttribute(name) ?? "");
85
86
  return Number.isNaN(parsed) ? fallback : parsed;
86
87
  }
87
- #setDefault(name, value) {
88
- if (!this.element.hasAttribute(name)) {
89
- this.element.setAttribute(name, value);
90
- }
91
- }
92
88
  };
93
89
 
94
90
  export { SeparatorController };
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/sidebar_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/escape_layer.ts
6
35
  var EscapeLayer = class _EscapeLayer {
7
36
  static #registries = /* @__PURE__ */ new WeakMap();
@@ -135,7 +164,7 @@ var FocusTrap = class {
135
164
  }
136
165
  if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
137
166
  document.addEventListener("keydown", this.#onKeydown);
138
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
167
+ this.#beforeCache.activate();
139
168
  const onEscape = this.#options.onEscape;
140
169
  if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
141
170
  if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
@@ -152,7 +181,7 @@ var FocusTrap = class {
152
181
  this.#activeState = false;
153
182
  this.#escapeLayer.deactivate();
154
183
  document.removeEventListener("keydown", this.#onKeydown);
155
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
184
+ this.#beforeCache.deactivate();
156
185
  if (this.#scrollLocked) {
157
186
  document.body.style.overflow = this.#previousBodyOverflow;
158
187
  this.#scrollLocked = false;
@@ -176,9 +205,7 @@ var FocusTrap = class {
176
205
  * untouched (restore-open designs reopen against a clean baseline), and focus
177
206
  * is left alone mid-navigation. The listener lives only while active.
178
207
  */
179
- #onBeforeCache = () => {
180
- this.deactivate({ restoreFocus: false });
181
- };
208
+ #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
182
209
  /**
183
210
  * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
184
211
  * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
@@ -474,7 +501,7 @@ var SidebarController = class extends Controller {
474
501
  key: { type: String, default: "" },
475
502
  collapsed: { type: Boolean, default: false }
476
503
  };
477
- static actions = ["beforeCache", "close", "open", "toggle"];
504
+ static actions = ["close", "open", "toggle"];
478
505
  /** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
479
506
  #activePanel = null;
480
507
  /** Owns the overlay modal side effects; Escape closes, focus falls to trigger. */
@@ -496,6 +523,7 @@ var SidebarController = class extends Controller {
496
523
  #connected = false;
497
524
  connect() {
498
525
  this.#connected = true;
526
+ this.#beforeCache.activate();
499
527
  this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
500
528
  this.#collapsed = this.#restoreCollapsed();
501
529
  this.#mqlQuery = this.#breakpointQuery;
@@ -505,6 +533,7 @@ var SidebarController = class extends Controller {
505
533
  }
506
534
  disconnect() {
507
535
  this.#connected = false;
536
+ this.#beforeCache.deactivate();
508
537
  this.#mql?.removeEventListener("change", this.#onMediaChange);
509
538
  this.#mql = null;
510
539
  this.#mqlQuery = null;
@@ -556,12 +585,12 @@ var SidebarController = class extends Controller {
556
585
  * closed immediately, and modal side effects are released without moving
557
586
  * focus during navigation.
558
587
  */
559
- beforeCache() {
588
+ #beforeCache = new BeforeCacheReset(() => {
560
589
  if (!this.#connected || !this.#isOverlay) return;
561
590
  this.#transition.cancel();
562
591
  this.#trap.deactivate({ restoreFocus: false });
563
592
  this.#setOverlayClosedImmediate();
564
- }
593
+ });
565
594
  /** Toggles the panel: inline flips collapsed/expanded, overlay flips open/closed. */
566
595
  toggle() {
567
596
  if (this.#isOverlay) {
@@ -2,6 +2,126 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/skeleton_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+ function fillTemplate(template, values) {
16
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
+ const replacement = values[name];
18
+ return replacement === void 0 ? match : String(replacement);
19
+ });
20
+ }
21
+
22
+ // src/utils/detach_gate.ts
23
+ var DetachGate = class _DetachGate {
24
+ /** Set while a probe is queued, waiting for a reconnect to cancel it. */
25
+ #pending = false;
26
+ /**
27
+ * True while a probe is queued — the last disconnect was ambiguous and no
28
+ * reconnect has cancelled it yet. Read it from `connect()` to tell the
29
+ * reconnect half of an in-page move from a first connect: a controller whose
30
+ * initialisation restarts a measurement (a min-duration floor, an elapsed
31
+ * counter) must skip it for the move, where nothing actually restarted.
32
+ */
33
+ get pending() {
34
+ return this.#pending;
35
+ }
36
+ /**
37
+ * True when the disconnect is definitely a real detach — the element left
38
+ * the document, or `data-controller` no longer lists the identifier. False
39
+ * means ambiguous (in-page move or observed-root exit), NOT "alive".
40
+ */
41
+ static isDetached(host) {
42
+ if (!host.element.isConnected) return true;
43
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
44
+ return !tokens.includes(host.identifier);
45
+ }
46
+ /**
47
+ * Call from `disconnect()`: runs `teardown` synchronously on a definite
48
+ * detach (fast path), otherwise defers it one microtask — a reconnect
49
+ * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
50
+ * One microtask is the whole probe window: Stimulus reconnects a moved
51
+ * element within the same mutation batch, before the checkpoint drains.
52
+ */
53
+ disconnected(host, teardown) {
54
+ if (_DetachGate.isDetached(host)) {
55
+ this.#pending = false;
56
+ teardown();
57
+ return;
58
+ }
59
+ this.#pending = true;
60
+ queueMicrotask(() => {
61
+ if (!this.#pending) return;
62
+ this.#pending = false;
63
+ teardown();
64
+ });
65
+ }
66
+ /**
67
+ * Disarms a pending probe. Call from `connect()` (the reconnect that proves
68
+ * an in-page move) and from the head of any teardown path not routed through
69
+ * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
70
+ * never run the teardown a second time.
71
+ */
72
+ cancel() {
73
+ this.#pending = false;
74
+ }
75
+ };
76
+
77
+ // src/utils/min_duration_floor.ts
78
+ var MinDurationFloor = class {
79
+ #timers;
80
+ /** Pending finish timer id, or `null` when nothing is held back. */
81
+ #timerId = null;
82
+ /** Epoch ms the floor is measured from. */
83
+ #since = 0;
84
+ /** @param timers - the controller's registry; the floor schedules into it. */
85
+ constructor(timers) {
86
+ this.#timers = timers;
87
+ }
88
+ /** Starts the floor: call when the state being held becomes visible. */
89
+ begin() {
90
+ this.#since = Date.now();
91
+ }
92
+ /** True while a finish is held back waiting for the floor to elapse. */
93
+ get pending() {
94
+ return this.#timerId !== null;
95
+ }
96
+ /**
97
+ * Runs `finish` once the floor has elapsed, immediately when it already has.
98
+ *
99
+ * A held-back finish is **replaced**, never stacked: only the most recently
100
+ * queued id is cancellable, so a second timer would outlive every cancel and
101
+ * end a state that has since restarted. Controllers that want the first signal
102
+ * to win guard on {@link pending} before calling.
103
+ */
104
+ schedule(minDuration, finish) {
105
+ this.cancel();
106
+ const remaining = minDuration - (Date.now() - this.#since);
107
+ if (remaining > 0) {
108
+ this.#timerId = this.#timers.set(() => {
109
+ this.#timerId = null;
110
+ finish();
111
+ }, remaining);
112
+ } else {
113
+ finish();
114
+ }
115
+ }
116
+ /** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
117
+ cancel() {
118
+ if (this.#timerId !== null) {
119
+ this.#timers.clear(this.#timerId);
120
+ this.#timerId = null;
121
+ }
122
+ }
123
+ };
124
+
5
125
  // src/utils/safe_timeout.ts
6
126
  var TimerRegistry = class {
7
127
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -59,48 +179,37 @@ var SafeTimeout = class extends TimerRegistry {
59
179
  var SkeletonController = class extends Controller {
60
180
  static targets = ["placeholder", "content"];
61
181
  static values = {
182
+ announceReadyText: { type: String, default: "" },
62
183
  minDuration: { type: Number, default: 0 }
63
184
  };
64
185
  static actions = ["ready", "reset"];
65
186
  static events = ["ready"];
66
187
  #timers = new SafeTimeout();
67
- /** Pending min-duration reveal timer id, or `null` when none is scheduled. */
68
- #revealTimerId = null;
69
- /** Epoch ms when the loading state began; `minDuration` is measured from it. */
70
- #loadingSince = 0;
188
+ #floor = new MinDurationFloor(this.#timers);
189
+ #gate = new DetachGate();
71
190
  connect() {
72
- if (this.#state !== "ready") {
191
+ const moved = this.#gate.pending;
192
+ this.#gate.cancel();
193
+ if (!moved && this.#state !== "ready") {
73
194
  this.#enterLoading();
74
195
  }
75
196
  }
76
197
  disconnect() {
77
- this.#timers.clearAll();
78
- this.#revealTimerId = null;
198
+ this.#gate.disconnected(this, () => this.#teardown());
79
199
  }
80
200
  /** Swaps to the real content. Honors `minDuration` to prevent a flash. */
81
201
  ready() {
82
- if (this.#state === "ready" || this.#revealTimerId !== null) return;
83
- const remaining = this.minDurationValue - (Date.now() - this.#loadingSince);
84
- if (remaining > 0) {
85
- this.#revealTimerId = this.#timers.set(() => {
86
- this.#revealTimerId = null;
87
- this.#reveal();
88
- }, remaining);
89
- } else {
90
- this.#reveal();
91
- }
202
+ if (this.#state === "ready" || this.#floor.pending) return;
203
+ this.#floor.schedule(this.minDurationValue, () => this.#reveal());
92
204
  }
93
205
  /** Returns to the loading state (e.g. a Turbo Stream re-fetch). */
94
206
  reset() {
95
- if (this.#revealTimerId !== null) {
96
- this.#timers.clear(this.#revealTimerId);
97
- this.#revealTimerId = null;
98
- }
207
+ this.#floor.cancel();
99
208
  this.#enterLoading();
100
209
  }
101
210
  /** Shows the placeholder, hides content, and marks the region busy. */
102
211
  #enterLoading() {
103
- this.#loadingSince = Date.now();
212
+ this.#floor.begin();
104
213
  if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = false;
105
214
  if (this.hasContentTarget) this.contentTarget.hidden = true;
106
215
  this.element.setAttribute("aria-busy", "true");
@@ -113,6 +222,18 @@ var SkeletonController = class extends Controller {
113
222
  this.element.setAttribute("aria-busy", "false");
114
223
  this.element.setAttribute("data-state", "ready");
115
224
  this.dispatch("ready", { detail: {} });
225
+ announce(fillTemplate(this.announceReadyTextValue, {}));
226
+ }
227
+ /**
228
+ * Drops the held reveal on a real detach. The markup keeps whatever it last
229
+ * held: an element on its way out of the document has no reader left, and one
230
+ * whose `data-controller` dropped the identifier no longer resolves its own
231
+ * targets, so the rollback could only ever be partial.
232
+ */
233
+ #teardown() {
234
+ this.#gate.cancel();
235
+ this.#timers.clearAll();
236
+ this.#floor.cancel();
116
237
  }
117
238
  /** Current lifecycle phase as reflected on `data-state`. */
118
239
  get #state() {