stimeo-ui 0.15.0 → 0.17.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +209 -0
  3. data/README.md +128 -1
  4. data/dist/cable/index.js +392 -417
  5. data/dist/controllers/accordion_controller.js +228 -28
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -191
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +295 -110
  10. data/dist/controllers/avatar_controller.js +172 -106
  11. data/dist/controllers/breadcrumb_controller.js +0 -129
  12. data/dist/controllers/bulk_select_controller.js +264 -53
  13. data/dist/controllers/calendar_controller.js +346 -180
  14. data/dist/controllers/carousel_controller.js +587 -300
  15. data/dist/controllers/character_counter_controller.js +271 -143
  16. data/dist/controllers/checkbox_controller.js +77 -75
  17. data/dist/controllers/clipboard_controller.js +187 -108
  18. data/dist/controllers/collapsible_controller.js +374 -108
  19. data/dist/controllers/color_picker_controller.js +183 -127
  20. data/dist/controllers/combobox_controller.js +426 -109
  21. data/dist/controllers/command_palette_controller.js +994 -350
  22. data/dist/controllers/conditional_fields_controller.js +212 -125
  23. data/dist/controllers/confirm_controller.js +939 -245
  24. data/dist/controllers/context_menu_controller.js +275 -127
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +235 -97
  27. data/dist/controllers/currency_input_controller.js +241 -118
  28. data/dist/controllers/data_grid_controller.js +229 -135
  29. data/dist/controllers/date_range_picker_controller.js +359 -165
  30. data/dist/controllers/dialog_controller.js +887 -225
  31. data/dist/controllers/direct_upload_controller.js +80 -142
  32. data/dist/controllers/dirty_form_controller.js +26 -47
  33. data/dist/controllers/dismissible_controller.js +175 -20
  34. data/dist/controllers/drawer_controller.js +963 -336
  35. data/dist/controllers/dropdown_controller.js +342 -85
  36. data/dist/controllers/editable_controller.js +182 -58
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +426 -229
  39. data/dist/controllers/filter_controller.js +78 -36
  40. data/dist/controllers/flash_controller.js +328 -211
  41. data/dist/controllers/focus_controller.js +654 -239
  42. data/dist/controllers/form_field_controller.js +153 -136
  43. data/dist/controllers/form_validation_controller.js +29 -96
  44. data/dist/controllers/frame_loading_controller.js +263 -198
  45. data/dist/controllers/highlight_controller.js +119 -70
  46. data/dist/controllers/hover_card_controller.js +335 -118
  47. data/dist/controllers/idle_controller.js +316 -65
  48. data/dist/controllers/input_mask_controller.js +116 -61
  49. data/dist/controllers/intersection_controller.js +147 -103
  50. data/dist/controllers/lazy_frame_controller.js +9 -56
  51. data/dist/controllers/listbox_controller.js +413 -153
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -95
  54. data/dist/controllers/menu_controller.js +254 -167
  55. data/dist/controllers/menubar_controller.js +206 -331
  56. data/dist/controllers/meter_controller.js +159 -71
  57. data/dist/controllers/multi_select_controller.js +797 -388
  58. data/dist/controllers/navigation_menu_controller.js +156 -231
  59. data/dist/controllers/nested_form_controller.js +373 -174
  60. data/dist/controllers/network_status_controller.js +241 -45
  61. data/dist/controllers/number_input_controller.js +387 -227
  62. data/dist/controllers/optimistic_controller.js +28 -75
  63. data/dist/controllers/otp_controller.js +344 -196
  64. data/dist/controllers/overflow_indicator_controller.js +167 -93
  65. data/dist/controllers/overflow_menu_controller.js +408 -277
  66. data/dist/controllers/pagination_controller.js +342 -120
  67. data/dist/controllers/password_reveal_controller.js +424 -99
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +252 -96
  70. data/dist/controllers/pointer_drag_controller.js +263 -164
  71. data/dist/controllers/popover_controller.js +267 -94
  72. data/dist/controllers/portal_controller.js +209 -72
  73. data/dist/controllers/preview_guard_controller.js +243 -112
  74. data/dist/controllers/progress_controller.js +132 -59
  75. data/dist/controllers/radio_group_controller.js +138 -133
  76. data/dist/controllers/range_slider_controller.js +245 -112
  77. data/dist/controllers/rating_controller.js +429 -194
  78. data/dist/controllers/read_more_controller.js +185 -47
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +294 -118
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -221
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -146
  87. data/dist/controllers/scrollspy_controller.js +181 -236
  88. data/dist/controllers/separator_controller.js +288 -132
  89. data/dist/controllers/sidebar_controller.js +1071 -375
  90. data/dist/controllers/skeleton_controller.js +248 -92
  91. data/dist/controllers/slider_controller.js +211 -96
  92. data/dist/controllers/smart_sticky_header_controller.js +163 -54
  93. data/dist/controllers/sortable_controller.js +13 -110
  94. data/dist/controllers/spinner_controller.js +277 -161
  95. data/dist/controllers/step_indicator_controller.js +107 -53
  96. data/dist/controllers/stepper_controller.js +171 -48
  97. data/dist/controllers/stick_to_bottom_controller.js +310 -116
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -192
  100. data/dist/controllers/switch_controller.js +107 -43
  101. data/dist/controllers/tabs_controller.js +50 -25
  102. data/dist/controllers/tags_input_controller.js +290 -149
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +148 -80
  105. data/dist/controllers/time_picker_controller.js +162 -81
  106. data/dist/controllers/toast_controller.js +365 -193
  107. data/dist/controllers/toggle_group_controller.js +194 -99
  108. data/dist/controllers/toolbar_controller.js +92 -90
  109. data/dist/controllers/tooltip_controller.js +185 -125
  110. data/dist/controllers/transition_controller.js +123 -115
  111. data/dist/controllers/tree_view_controller.js +248 -290
  112. data/dist/index.js +8649 -8572
  113. data/dist/positioning/index.js +148 -45
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,73 +1,62 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/announcer_controller.ts
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);
3
+ var DetachGate = class _DetachGate {
4
+ #pending = false;
5
+ get pending() {
6
+ return this.#pending;
7
+ }
8
+ static isDetached(host) {
9
+ return !host.element.isConnected || !_DetachGate.#listed(host);
10
+ }
11
+ disconnected(host, teardown) {
12
+ const carried = _DetachGate.#carried(host);
13
+ if (!carried && _DetachGate.isDetached(host)) {
14
+ this.#pending = false;
15
+ teardown();
16
+ return;
24
17
  }
18
+ this.#pending = true;
19
+ const probe = () => {
20
+ if (!this.#pending) return;
21
+ this.#pending = false;
22
+ teardown();
23
+ };
24
+ if (carried) setTimeout(probe, 0);
25
+ else queueMicrotask(probe);
26
+ }
27
+ static #carried(host) {
28
+ const element = host.element;
29
+ return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
30
+ }
31
+ static #listed(host) {
32
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
33
+ return tokens.includes(host.identifier);
25
34
  }
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);
35
+ cancel() {
36
+ this.#pending = false;
31
37
  }
32
38
  };
33
39
 
34
- // src/utils/safe_timeout.ts
40
+ var MAX_TIMER_DELAY_MS = 2147483647;
41
+
35
42
  var TimerRegistry = class {
36
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
37
43
  ids = /* @__PURE__ */ new Set();
38
- /**
39
- * Cancels a single tracked timer.
40
- *
41
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
42
- * registry), so callers can clear defensively without guarding.
43
- */
44
44
  clear(id) {
45
45
  if (this.ids.delete(id)) {
46
46
  this.cancel(id);
47
47
  }
48
48
  }
49
- /**
50
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
51
- * to guarantee no timer outlives the element.
52
- */
53
49
  clearAll() {
54
50
  for (const id of this.ids) {
55
51
  this.cancel(id);
56
52
  }
57
53
  this.ids.clear();
58
54
  }
59
- /** Number of timers currently tracked (pending). */
60
55
  get size() {
61
56
  return this.ids.size;
62
57
  }
63
58
  };
64
59
  var SafeTimeout = class extends TimerRegistry {
65
- /**
66
- * Schedules `callback` after `delay` ms and returns the timer id.
67
- *
68
- * The id is removed from the registry automatically when the timeout fires,
69
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
70
- */
71
60
  set(callback, delay) {
72
61
  const id = this.schedule(() => {
73
62
  this.ids.delete(id);
@@ -84,16 +73,9 @@ var SafeTimeout = class extends TimerRegistry {
84
73
  }
85
74
  };
86
75
 
87
- // src/utils/keyed_timers.ts
88
76
  var KeyedTimers = class {
89
77
  #timers = new SafeTimeout();
90
- /** The pending timer of each key; an entry lives exactly as long as its timer. */
91
78
  #pending = /* @__PURE__ */ new Map();
92
- /**
93
- * Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
94
- * pending. The entry is dropped before the callback runs, so the callback sees
95
- * the key unarmed and may arm it again for the next round.
96
- */
97
79
  set(key, callback, delay) {
98
80
  this.clear(key);
99
81
  const id = this.#timers.set(() => {
@@ -102,52 +84,35 @@ var KeyedTimers = class {
102
84
  }, delay);
103
85
  this.#pending.set(key, id);
104
86
  }
105
- /**
106
- * Cancels `key`'s pending timer, if it has one.
107
- *
108
- * A timer id is a positive integer, so `-1` stands for "nothing pending" and
109
- * the registry ignores an id it does not own — the unarmed case needs no
110
- * branch of its own, and no other key's timer can be reached from here.
111
- */
112
87
  clear(key) {
113
88
  this.#timers.clear(this.#pending.get(key) ?? -1);
114
89
  this.#pending.delete(key);
115
90
  }
116
- /**
117
- * Cancels every pending timer and forgets every key. Call this from a
118
- * controller's `disconnect()` so no timer, and no entry, outlives the element.
119
- */
120
91
  clearAll() {
121
92
  this.#timers.clearAll();
122
93
  this.#pending.clear();
123
94
  }
124
- /** Whether `key` has a timer pending. */
125
95
  has(key) {
126
96
  return this.#pending.has(key);
127
97
  }
128
98
  };
129
99
 
130
- // src/utils/microtask_coalescer.ts
131
100
  var MicrotaskCoalescer = class {
132
101
  #run;
133
102
  #queued = false;
134
103
  #active = false;
135
104
  #generation = 0;
136
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
137
105
  constructor(run) {
138
106
  this.#run = run;
139
107
  }
140
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
141
108
  activate() {
142
109
  this.#active = true;
143
110
  }
144
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
145
111
  cancel() {
146
112
  this.#active = false;
147
113
  this.#queued = false;
148
114
  this.#generation += 1;
149
115
  }
150
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
151
116
  schedule() {
152
117
  if (!this.#active || this.#queued) return;
153
118
  this.#queued = true;
@@ -160,49 +125,81 @@ var MicrotaskCoalescer = class {
160
125
  }
161
126
  };
162
127
 
163
- // src/controllers/announcer_controller.ts
128
+ var NUMBER_BOUNDS = {
129
+ timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS }};
130
+ function matchesNumberBounds(value, bounds) {
131
+ if (!Number.isFinite(value)) {
132
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
133
+ if (direction === null) return false;
134
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
135
+ }
136
+ if (bounds.min !== void 0 && value < bounds.min) return false;
137
+ if (bounds.max !== void 0 && value > bounds.max) return false;
138
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
139
+ if (bounds.integer && !Number.isInteger(value)) return false;
140
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
141
+ return true;
142
+ }
143
+
144
+ function readNumber(raw, fallback, bounds) {
145
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
146
+ }
147
+
148
+ var NumberValueReader = class {
149
+ #lastRejected = /* @__PURE__ */ new Map();
150
+ read(owner, name, raw, fallback, bounds) {
151
+ const resolved = readNumber(raw, fallback, bounds);
152
+ if (matchesNumberBounds(raw, bounds)) {
153
+ this.#lastRejected.delete(name);
154
+ return resolved;
155
+ }
156
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
157
+ const literal = owner.element.getAttribute(attribute);
158
+ if (literal === null) {
159
+ this.#lastRejected.delete(name);
160
+ return resolved;
161
+ }
162
+ if (this.#lastRejected.get(name) !== literal) {
163
+ this.#lastRejected.set(name, literal);
164
+ console.warn(
165
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
166
+ );
167
+ }
168
+ return resolved;
169
+ }
170
+ };
171
+
164
172
  var LEVELS = ["polite", "assertive"];
165
- var AnnouncerController = class extends Controller {
173
+ var STAND_IN = "stand-in";
174
+ var ANNOUNCED = "announced";
175
+ var AnnouncerController = class _AnnouncerController extends Controller {
176
+ #numbers = new NumberValueReader();
166
177
  static targets = ["polite", "assertive"];
167
178
  static values = {
168
179
  clearAfter: { type: Number, default: 1e3 },
169
180
  dedupeReannounce: { type: Boolean, default: true }
170
181
  };
182
+ static valueConstraints = {
183
+ clearAfter: NUMBER_BOUNDS.timer
184
+ };
171
185
  static actions = ["announce"];
172
- /** Drain timers, which belong to a politeness level rather than to a region. */
173
186
  #timers = new SafeTimeout();
174
- /**
175
- * The one timer a region may have outstanding — its dedupe re-set, then its
176
- * auto-clear. A region carries at most one, and the registry releases the
177
- * previous one whenever a newer message takes the region over.
178
- */
187
+ #gate = new DetachGate();
179
188
  #regionTimers = new KeyedTimers();
180
- /** Live regions generated to stand in for absent targets, for teardown. */
181
189
  #generated = /* @__PURE__ */ new Map();
182
- /** Messages waiting to be written, oldest first, one queue per politeness. */
190
+ get #standIn() {
191
+ return `data-${this.identifier}-${STAND_IN}`;
192
+ }
193
+ get #announced() {
194
+ return `data-${this.identifier}-${ANNOUNCED}`;
195
+ }
183
196
  #queues = /* @__PURE__ */ new Map();
184
- /** Politeness levels whose next drain is already armed. */
185
197
  #draining = /* @__PURE__ */ new Set();
186
- /** Collapses a batch of target callbacks (and morph removals) into one pass. */
187
198
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileRegions());
188
- /**
189
- * Watches the host's own children for a generated region disappearing. A morph
190
- * drops it — the server's HTML never had it — and no target callback reports
191
- * that, because a generated region carries no target attribute. `subtree` stays
192
- * off so writing a message inside a region does not re-enter this pass.
193
- */
194
199
  #hostWatch = new MutationObserver(() => {
195
200
  this.#reconcile.schedule();
196
201
  });
197
- /** Rewinds to an announceable initial state for the snapshot; see the remarks. */
198
- #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
199
- /**
200
- * Guards against handling the same CustomEvent twice. An event dispatched on
201
- * the controller element with `bubbles: true` reaches both the element and the
202
- * `window` listener; this WeakSet ensures it announces only once.
203
- */
204
202
  #handled = /* @__PURE__ */ new WeakSet();
205
- /** Receives programmatic announcements at the element or bubbled to `window`. */
206
203
  #onAnnounceEvent = (event) => {
207
204
  if (this.#handled.has(event)) return;
208
205
  this.#handled.add(event);
@@ -212,17 +209,23 @@ var AnnouncerController = class extends Controller {
212
209
  this.#announce(message, this.#assertiveFromDetail(detail));
213
210
  };
214
211
  connect() {
212
+ const moved = this.#gate.pending;
213
+ this.#gate.cancel();
214
+ if (moved) return;
215
215
  this.#reconcile.activate();
216
- this.#beforeCache.activate();
216
+ this.#dropInheritedStandIns();
217
+ this.#emptyInheritedMessages();
217
218
  this.#reconcileRegions();
218
219
  this.#hostWatch.observe(this.element, { childList: true });
219
220
  this.element.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
220
221
  window.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
221
222
  }
222
223
  disconnect() {
224
+ this.#gate.disconnected(this, () => this.#teardown());
225
+ }
226
+ #teardown() {
223
227
  this.#reconcile.cancel();
224
228
  this.#hostWatch.disconnect();
225
- this.#beforeCache.deactivate();
226
229
  this.element.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
227
230
  window.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
228
231
  this.#timers.clearAll();
@@ -231,39 +234,23 @@ var AnnouncerController = class extends Controller {
231
234
  this.#draining.clear();
232
235
  this.#removeGenerated();
233
236
  }
234
- /** Retires the stand-in once the consumer supplies a polite region. */
235
237
  politeTargetConnected() {
236
238
  this.#reconcile.schedule();
237
239
  }
238
- /** Materialises a stand-in once the consumer's polite region goes away. */
239
240
  politeTargetDisconnected() {
240
241
  this.#reconcile.schedule();
241
242
  }
242
- /** Retires the stand-in once the consumer supplies an assertive region. */
243
243
  assertiveTargetConnected() {
244
244
  this.#reconcile.schedule();
245
245
  }
246
- /** Materialises a stand-in once the consumer's assertive region goes away. */
247
246
  assertiveTargetDisconnected() {
248
247
  this.#reconcile.schedule();
249
248
  }
250
- /**
251
- * Brings the region set back to exactly one region per politeness and reports
252
- * whether anything had to be created.
253
- *
254
- * A region created here is not written to in the same task: assistive tech
255
- * reports changes to regions it already knows about, so {@link drain} waits a
256
- * task whenever this says a region is new.
257
- */
258
249
  #reconcileRegions() {
259
250
  let created = false;
260
251
  for (const level of LEVELS) {
261
252
  if (this.#hasTargetFor(level)) {
262
- const generated = this.#generated.get(level);
263
- if (generated) {
264
- generated.remove();
265
- this.#generated.delete(level);
266
- }
253
+ this.#generated.get(level)?.remove();
267
254
  continue;
268
255
  }
269
256
  const existing = this.#generated.get(level);
@@ -273,38 +260,33 @@ var AnnouncerController = class extends Controller {
273
260
  }
274
261
  return created;
275
262
  }
276
- /** Whether the consumer supplied a target for `level`. */
277
263
  #hasTargetFor(level) {
278
264
  return level === "assertive" ? this.hasAssertiveTarget : this.hasPoliteTarget;
279
265
  }
280
- /** Builds a visually hidden live region for `level` and attaches it. */
281
266
  #createRegion(level) {
282
267
  const region = document.createElement("div");
283
268
  region.setAttribute("aria-live", level);
284
269
  region.setAttribute("aria-atomic", "true");
270
+ region.setAttribute(this.#standIn, "");
285
271
  visuallyHide(region);
286
272
  this.element.appendChild(region);
287
273
  return region;
288
274
  }
289
- /**
290
- * Removes and forgets every region this controller generated. Authored targets
291
- * belong to the consumer and are left untouched. Forgetting them is what keeps
292
- * the live page working after a snapshot rewind: the next announcement finds an
293
- * empty map and materialises a fresh region.
294
- */
295
- #removeGenerated() {
296
- for (const region of this.#generated.values()) {
297
- region.remove();
275
+ #dropInheritedStandIns() {
276
+ for (const child of Array.from(this.element.children)) {
277
+ if (child.hasAttribute(this.#standIn)) child.remove();
278
+ }
279
+ }
280
+ #emptyInheritedMessages() {
281
+ for (const region of [...this.politeTargets, ...this.assertiveTargets]) {
282
+ if (!region.hasAttribute(this.#announced)) continue;
283
+ region.replaceChildren();
284
+ region.removeAttribute(this.#announced);
298
285
  }
299
- this.#generated.clear();
300
- }
301
- /**
302
- * Announces a message. Reads the text from a Stimulus action param
303
- * (`message`, plus optional `assertive`) for attribute-only triggers, falling
304
- * back to a CustomEvent `detail` when the same handler is wired to an event.
305
- * An empty/non-string message is ignored so untrusted payloads cannot blank
306
- * the region.
307
- */
286
+ }
287
+ #removeGenerated() {
288
+ for (const region of this.#generated.values()) region.remove();
289
+ }
308
290
  announce(event) {
309
291
  const params = event.params;
310
292
  const fromParam = params?.message;
@@ -313,13 +295,6 @@ var AnnouncerController = class extends Controller {
313
295
  const assertive = params?.assertive === true || this.#assertiveFromDetail(event.detail);
314
296
  this.#announce(message, assertive);
315
297
  }
316
- /**
317
- * Queues `message` for its politeness and arms the drain.
318
- *
319
- * Queuing is what makes a burst audible: assistive tech announces the changes it
320
- * observes, so several messages written into one region within a single task are
321
- * one change and only the last is read.
322
- */
323
298
  #announce(message, assertive) {
324
299
  const level = assertive ? "assertive" : "polite";
325
300
  const queue = this.#queues.get(level);
@@ -330,7 +305,6 @@ var AnnouncerController = class extends Controller {
330
305
  }
331
306
  this.#scheduleDrain(level);
332
307
  }
333
- /** Arms one drain pass for `level`; further messages ride the pass already armed. */
334
308
  #scheduleDrain(level) {
335
309
  if (this.#draining.has(level)) return;
336
310
  this.#draining.add(level);
@@ -339,25 +313,17 @@ var AnnouncerController = class extends Controller {
339
313
  this.#drain(level);
340
314
  }, 0);
341
315
  }
342
- /**
343
- * Writes one queued message, then arms the next pass while the queue holds more.
344
- *
345
- * Two steps take a whole pass without consuming the message: materialising a
346
- * region (it has to be in the accessibility tree before the text arrives) and
347
- * emptying a region that already holds this exact text (an unchanged node is not
348
- * re-read, so `dedupeReannounce` clears first and writes on the following pass).
349
- */
350
316
  #drain(level) {
351
317
  const queue = this.#queues.get(level);
352
318
  const message = queue?.[0];
353
319
  if (queue === void 0 || message === void 0) return;
354
- if (this.#reconcileRegions()) {
320
+ const created = this.#reconcileRegions();
321
+ const region = this.#regionFor(level);
322
+ if (created || region === void 0) {
355
323
  this.#scheduleDrain(level);
356
324
  return;
357
325
  }
358
- const region = this.#regionFor(level);
359
326
  if (this.dedupeReannounceValue && region.textContent === message) {
360
- this.#regionTimers.clear(region);
361
327
  region.textContent = "";
362
328
  this.#scheduleDrain(level);
363
329
  return;
@@ -365,56 +331,27 @@ var AnnouncerController = class extends Controller {
365
331
  queue.shift();
366
332
  this.#regionTimers.clear(region);
367
333
  region.textContent = message;
334
+ region.setAttribute(this.#announced, "");
368
335
  this.#scheduleClear(region, message);
369
336
  if (queue.length > 0) this.#scheduleDrain(level);
370
337
  }
371
- /** Clears the region after `clearAfter` ms, unless a newer message replaced it. */
372
338
  #scheduleClear(region, message) {
373
- if (this.clearAfterValue <= 0) return;
339
+ if (this.#safeClearAfter <= 0) return;
374
340
  this.#regionTimers.set(
375
341
  region,
376
342
  () => {
377
- if (region.textContent === message) region.textContent = "";
343
+ if (region.textContent !== message) return;
344
+ region.textContent = "";
345
+ region.removeAttribute(this.#announced);
378
346
  },
379
- this.clearAfterValue
347
+ this.#safeClearAfter
380
348
  );
381
349
  }
382
- /**
383
- * Resolves the live region for a politeness level.
384
- *
385
- * The remembered stand-in is used only while it is still in the document: a morph
386
- * can drop it, and writing into the detached node would announce nothing at all.
387
- */
388
350
  #regionFor(level) {
389
351
  if (level === "assertive" && this.hasAssertiveTarget) return this.assertiveTarget;
390
352
  if (level === "polite" && this.hasPoliteTarget) return this.politeTarget;
391
- const existing = this.#generated.get(level);
392
- if (existing?.isConnected) return existing;
393
- const region = this.#createRegion(level);
394
- this.#generated.set(level, region);
395
- return region;
353
+ return this.#generated.get(level);
396
354
  }
397
- /**
398
- * Restores the announceable initial state for the snapshot Turbo is about to
399
- * take: queued and displayed messages go, generated regions go, and the live
400
- * page — which keeps running when a visit is aborted — gets its regions back on
401
- * the next task, after the clone.
402
- */
403
- #rewindForCache() {
404
- this.#queues.clear();
405
- this.#draining.clear();
406
- this.#timers.clearAll();
407
- this.#regionTimers.clearAll();
408
- for (const level of LEVELS) {
409
- if (this.#hasTargetFor(level)) {
410
- const target = level === "assertive" ? this.assertiveTarget : this.politeTarget;
411
- target.textContent = "";
412
- }
413
- }
414
- this.#removeGenerated();
415
- this.#timers.set(() => this.#reconcileRegions(), 0);
416
- }
417
- /** Extracts a non-empty string `message` from a CustomEvent detail, else null. */
418
355
  #messageFromDetail(detail) {
419
356
  if (detail && typeof detail === "object" && "message" in detail) {
420
357
  const value = detail.message;
@@ -422,10 +359,18 @@ var AnnouncerController = class extends Controller {
422
359
  }
423
360
  return null;
424
361
  }
425
- /** Reads an `assertive === true` flag from a CustomEvent detail (default polite). */
426
362
  #assertiveFromDetail(detail) {
427
363
  return !!detail && typeof detail === "object" && detail.assertive === true;
428
364
  }
365
+ get #safeClearAfter() {
366
+ return this.#numbers.read(
367
+ this,
368
+ "clearAfter",
369
+ this.clearAfterValue,
370
+ _AnnouncerController.values.clearAfter.default,
371
+ _AnnouncerController.valueConstraints.clearAfter
372
+ );
373
+ }
429
374
  };
430
375
  function visuallyHide(node) {
431
376
  const { style } = node;
@@ -442,5 +387,4 @@ function visuallyHide(node) {
442
387
  }
443
388
 
444
389
  export { AnnouncerController, visuallyHide };
445
- //# sourceMappingURL=announcer_controller.js.map
446
390
  //# sourceMappingURL=announcer_controller.js.map
@@ -1,6 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/aspect_ratio_controller.ts
4
3
  var DEFAULT_RATIO = "1 / 1";
5
4
  var RATIO_PROPERTY = "--stimeo--aspect-ratio";
6
5
  var CSS_NUMBER_PATTERN = /^[+-]?(?:\d+|\d*\.\d+)(?:[eE][+-]?\d+)?$/;
@@ -8,16 +7,9 @@ var AspectRatioController = class extends Controller {
8
7
  static values = {
9
8
  ratio: { type: String, default: "1/1" }
10
9
  };
11
- /** Applies the ratio on connect and whenever the value changes. */
12
10
  ratioValueChanged() {
13
11
  this.element.style.setProperty(RATIO_PROPERTY, this.#normalizeRatio(this.ratioValue));
14
12
  }
15
- /**
16
- * Normalizes a ratio string to a valid CSS `<ratio>`:
17
- * - `"16/9"` / `"16 / 9"` → `"16 / 9"` (both parts must be positive CSS numbers)
18
- * - `"1.5"` → `"1.5"` (a bare positive CSS number)
19
- * - anything else → `"1 / 1"` (the default), so the custom property is always valid.
20
- */
21
13
  #normalizeRatio(raw) {
22
14
  const value = raw.trim();
23
15
  if (value.includes("/")) {
@@ -30,7 +22,6 @@ var AspectRatioController = class extends Controller {
30
22
  const single = this.#parsePositiveNumber(value);
31
23
  return single === void 0 ? DEFAULT_RATIO : String(single);
32
24
  }
33
- /** Parses one complete CSS `<number>` token and rejects units or trailing syntax. */
34
25
  #parsePositiveNumber(raw) {
35
26
  const token = raw.trim();
36
27
  if (!CSS_NUMBER_PATTERN.test(token)) return void 0;
@@ -40,5 +31,4 @@ var AspectRatioController = class extends Controller {
40
31
  };
41
32
 
42
33
  export { AspectRatioController };
43
- //# sourceMappingURL=aspect_ratio_controller.js.map
44
34
  //# sourceMappingURL=aspect_ratio_controller.js.map