stimeo-ui 0.14.0 → 0.16.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +81 -12
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +85 -17
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +46 -13
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +432 -71
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/flash_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
+
5
16
  // src/utils/before_cache_reset.ts
6
17
  var BeforeCacheReset = class _BeforeCacheReset {
7
18
  /** Every subscribed instance, iterated by the one shared document listener. */
@@ -31,6 +42,15 @@ var BeforeCacheReset = class _BeforeCacheReset {
31
42
  }
32
43
  };
33
44
 
45
+ // src/utils/event_owner.ts
46
+ function ownerIndex(candidates, node) {
47
+ if (!(node instanceof Node)) return -1;
48
+ return candidates.findIndex((candidate) => candidate.contains(node));
49
+ }
50
+ function ownerOf(candidates, node) {
51
+ return candidates[ownerIndex(candidates, node)] ?? null;
52
+ }
53
+
34
54
  // src/utils/safe_timeout.ts
35
55
  var TimerRegistry = class {
36
56
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -84,6 +104,207 @@ var SafeTimeout = class extends TimerRegistry {
84
104
  }
85
105
  };
86
106
 
107
+ // src/utils/keyed_timers.ts
108
+ var KeyedTimers = class {
109
+ #timers = new SafeTimeout();
110
+ /** The pending timer of each key; an entry lives exactly as long as its timer. */
111
+ #pending = /* @__PURE__ */ new Map();
112
+ /**
113
+ * Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
114
+ * pending. The entry is dropped before the callback runs, so the callback sees
115
+ * the key unarmed and may arm it again for the next round.
116
+ */
117
+ set(key, callback, delay) {
118
+ this.clear(key);
119
+ const id = this.#timers.set(() => {
120
+ this.#pending.delete(key);
121
+ callback();
122
+ }, delay);
123
+ this.#pending.set(key, id);
124
+ }
125
+ /**
126
+ * Cancels `key`'s pending timer, if it has one.
127
+ *
128
+ * A timer id is a positive integer, so `-1` stands for "nothing pending" and
129
+ * the registry ignores an id it does not own — the unarmed case needs no
130
+ * branch of its own, and no other key's timer can be reached from here.
131
+ */
132
+ clear(key) {
133
+ this.#timers.clear(this.#pending.get(key) ?? -1);
134
+ this.#pending.delete(key);
135
+ }
136
+ /**
137
+ * Cancels every pending timer and forgets every key. Call this from a
138
+ * controller's `disconnect()` so no timer, and no entry, outlives the element.
139
+ */
140
+ clearAll() {
141
+ this.#timers.clearAll();
142
+ this.#pending.clear();
143
+ }
144
+ /** Whether `key` has a timer pending. */
145
+ has(key) {
146
+ return this.#pending.has(key);
147
+ }
148
+ };
149
+
150
+ // src/utils/listener_set.ts
151
+ var ListenerSet = class {
152
+ /** The generation every `add` joins until the next `dispose()`. */
153
+ #abort = new AbortController();
154
+ /**
155
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
156
+ *
157
+ * The set supplies the signal, so `options` carries everything else the DOM
158
+ * accepts — `capture` included, which has to match at release time and no
159
+ * longer has a second place to drift from.
160
+ */
161
+ add(target, type, handler, options) {
162
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
163
+ }
164
+ /**
165
+ * Releases every listener of the open generation, synchronously, and opens the
166
+ * next one. Idempotent, and safe before anything has been added.
167
+ */
168
+ dispose() {
169
+ this.#abort.abort();
170
+ this.#abort = new AbortController();
171
+ }
172
+ };
173
+
174
+ // src/utils/microtask_coalescer.ts
175
+ var MicrotaskCoalescer = class {
176
+ #run;
177
+ #queued = false;
178
+ #active = false;
179
+ #generation = 0;
180
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
181
+ constructor(run) {
182
+ this.#run = run;
183
+ }
184
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
185
+ activate() {
186
+ this.#active = true;
187
+ }
188
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
189
+ cancel() {
190
+ this.#active = false;
191
+ this.#queued = false;
192
+ this.#generation += 1;
193
+ }
194
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
195
+ schedule() {
196
+ if (!this.#active || this.#queued) return;
197
+ this.#queued = true;
198
+ const generation = this.#generation;
199
+ queueMicrotask(() => {
200
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
201
+ this.#queued = false;
202
+ this.#run();
203
+ });
204
+ }
205
+ };
206
+
207
+ // src/utils/pausable_timers.ts
208
+ var PausableTimers = class {
209
+ #timers = new KeyedTimers();
210
+ /** Every key armed or held; an entry goes as its timer fires, or with the key. */
211
+ #entries = /* @__PURE__ */ new Map();
212
+ /**
213
+ * Arms `callback` after `delay` ms for `key`, replacing whatever `key` had.
214
+ * A held key stays held and banks `delay` for its resume.
215
+ */
216
+ set(key, callback, delay) {
217
+ const reasons = this.#entries.get(key)?.reasons ?? /* @__PURE__ */ new Set();
218
+ const entry = { callback, startedAt: 0, remaining: delay, reasons };
219
+ this.#entries.set(key, entry);
220
+ if (reasons.size === 0) this.#arm(key, entry, callback);
221
+ }
222
+ /**
223
+ * Holds `key` for `reason` and banks the time left, with a floor of one
224
+ * millisecond. A key with no timer is held all the same. Reports whether this
225
+ * call is the one that stopped a running timer, so the caller can write its
226
+ * held-state hook exactly once.
227
+ */
228
+ pause(key, reason) {
229
+ let entry = this.#entries.get(key);
230
+ if (entry === void 0) {
231
+ entry = { callback: null, startedAt: 0, remaining: 0, reasons: /* @__PURE__ */ new Set() };
232
+ this.#entries.set(key, entry);
233
+ }
234
+ entry.reasons.add(reason);
235
+ if (!this.#timers.has(key)) return false;
236
+ this.#timers.clear(key);
237
+ entry.remaining = Math.max(1, entry.remaining - (Date.now() - entry.startedAt));
238
+ return true;
239
+ }
240
+ /**
241
+ * Releases `reason` on `key`, arming the banked time again once no reason is
242
+ * left; a key released with no timer is forgotten. Reports whether this call
243
+ * is the one that started the timer again.
244
+ */
245
+ resume(key, reason) {
246
+ const entry = this.#entries.get(key);
247
+ if (entry === void 0) return false;
248
+ entry.reasons.delete(reason);
249
+ if (entry.reasons.size > 0 || this.#timers.has(key)) return false;
250
+ const { callback } = entry;
251
+ if (callback === null) {
252
+ this.#entries.delete(key);
253
+ return false;
254
+ }
255
+ this.#arm(key, entry, callback);
256
+ return true;
257
+ }
258
+ /** Whether any reason holds `key`, with or without a timer. */
259
+ isHeld(key) {
260
+ return (this.#entries.get(key)?.reasons.size ?? 0) > 0;
261
+ }
262
+ /** Whether `key` is armed or held — that is, whether this registry drives it at all. */
263
+ tracks(key) {
264
+ return this.#entries.has(key);
265
+ }
266
+ /**
267
+ * Cancels `key`'s timer and keeps its hold, so a release arms nothing; a key
268
+ * nothing holds is forgotten.
269
+ */
270
+ disarm(key) {
271
+ this.#timers.clear(key);
272
+ const entry = this.#entries.get(key);
273
+ if (entry?.reasons.size) entry.callback = null;
274
+ else this.#entries.delete(key);
275
+ }
276
+ /** Cancels `key`'s timer and drops its hold. */
277
+ clear(key) {
278
+ this.#timers.clear(key);
279
+ this.#entries.delete(key);
280
+ }
281
+ /**
282
+ * Cancels every timer and forgets every key. Call this from a controller's
283
+ * `disconnect()` so neither a timer nor a hold outlives the element.
284
+ */
285
+ clearAll() {
286
+ this.#timers.clearAll();
287
+ this.#entries.clear();
288
+ }
289
+ /** Starts `entry`'s banked time running for `key`, and drops it as `callback` fires. */
290
+ #arm(key, entry, callback) {
291
+ entry.startedAt = Date.now();
292
+ this.#timers.set(
293
+ key,
294
+ () => {
295
+ this.#entries.delete(key);
296
+ callback();
297
+ },
298
+ entry.remaining
299
+ );
300
+ }
301
+ };
302
+
303
+ // src/utils/target_selector.ts
304
+ function targetSelector(identifier, name) {
305
+ return `[data-${identifier}-target~="${name}"]`;
306
+ }
307
+
87
308
  // src/utils/transition_completion.ts
88
309
  function timeMs(value) {
89
310
  const trimmed = value.trim();
@@ -120,8 +341,12 @@ function maxTransitionTotalMs(style) {
120
341
 
121
342
  // src/controllers/flash_controller.ts
122
343
  var ASSERTIVE_TYPES = /* @__PURE__ */ new Set(["alert", "error"]);
123
- var MESSAGE_SELECTOR = '[data-stimeo--flash-target="message"]';
344
+ var MESSAGE_PART = "message";
124
345
  var FlashController = class extends Controller {
346
+ /** Selects the message parts, in the namespace this controller is registered under. */
347
+ get #messageSelector() {
348
+ return targetSelector(this.identifier, MESSAGE_PART);
349
+ }
125
350
  static targets = ["region", "message"];
126
351
  static values = {
127
352
  duration: { type: Number, default: 5e3 },
@@ -130,12 +355,35 @@ var FlashController = class extends Controller {
130
355
  };
131
356
  static actions = ["dismiss"];
132
357
  static events = ["show", "dismiss", "reconcile"];
358
+ /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
133
359
  #timers = new SafeTimeout();
360
+ /**
361
+ * Per-message auto-dismiss held open while the message is hovered or focused, and
362
+ * the one record of which messages hover or focus holds, which the cap reads.
363
+ */
364
+ #dismiss = new PausableTimers();
365
+ /**
366
+ * Auto-dismiss of the messages taken on while `pauseOnHover` is off. It runs on
367
+ * whatever holds the message; the hold itself still keeps the cap away.
368
+ */
369
+ #fixedDismiss = new KeyedTimers();
370
+ /** Applies the cap again once the event that released a hold has run its course. */
371
+ #reapply = new MicrotaskCoalescer(() => this.#reapplyCap());
372
+ /** Messages the pointer or focus moved into since the cap was last applied again. */
373
+ #spared = [];
374
+ /** The next pointer movement, listened for while a hover hold waits on it. */
375
+ #pointer = new ListenerSet();
376
+ /** Messages whose hover hold waits for the next pointer movement to be confirmed. */
377
+ #awaitingPointer = /* @__PURE__ */ new Set();
134
378
  #observer = null;
135
379
  /** Whether the controller is between `connect()` and `disconnect()`. */
136
380
  #connected = false;
137
- /** Auto-dismiss timer state keyed by message element. */
138
- #state = /* @__PURE__ */ new Map();
381
+ /**
382
+ * The `max` the stack was last held to, or `null` before the first time. `connect()`
383
+ * applies the cap only when `max` is not that one, so a reconnect of the same instance
384
+ * leaves the stack as it was unless `max` changed while it was away.
385
+ */
386
+ #appliedMax = null;
139
387
  /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
140
388
  #order = [];
141
389
  /**
@@ -147,12 +395,28 @@ var FlashController = class extends Controller {
147
395
  #leaving = /* @__PURE__ */ new Set();
148
396
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
149
397
  #onEnter = (event) => this.#pause(event.currentTarget, event.type === "focusin" ? "focus" : "hover");
150
- #onLeave = (event) => this.#resume(event.currentTarget, event.type === "focusout" ? "focus" : "hover");
398
+ /**
399
+ * Releases hover or focus on a message. Focus moving between two of the message's
400
+ * own controls is no release, since `focusout` names a control still inside it; and
401
+ * the message the pointer or focus is moving into is spared when the release
402
+ * applies the cap again, because its own hold only arrives after this event.
403
+ */
404
+ #onLeave = (event) => {
405
+ const message = event.currentTarget;
406
+ const next = ownerOf(this.#order, event.relatedTarget);
407
+ if (next === message) return;
408
+ this.#resume(message, event.type === "focusout" ? "focus" : "hover", next);
409
+ };
151
410
  connect() {
152
411
  this.#connected = true;
412
+ this.#reapply.activate();
153
413
  for (const message of this.messageTargets) {
154
414
  if (this.#owns(message)) this.#process(message, true);
155
415
  }
416
+ if (!Object.is(this.#appliedMax, this.maxValue)) {
417
+ this.#appliedMax = this.maxValue;
418
+ this.#enforceMax();
419
+ }
156
420
  this.#syncObservation();
157
421
  this.#beforeCache.activate();
158
422
  }
@@ -161,8 +425,13 @@ var FlashController = class extends Controller {
161
425
  this.#beforeCache.deactivate();
162
426
  this.#stopObserving();
163
427
  this.#timers.clearAll();
428
+ this.#dismiss.clearAll();
429
+ this.#fixedDismiss.clearAll();
430
+ this.#reapply.cancel();
431
+ this.#spared = [];
432
+ this.#pointer.dispose();
433
+ this.#awaitingPointer.clear();
164
434
  for (const message of this.#order) this.#unbindPause(message);
165
- this.#state.clear();
166
435
  this.#order.length = 0;
167
436
  this.#leaving.clear();
168
437
  }
@@ -192,6 +461,19 @@ var FlashController = class extends Controller {
192
461
  regionTargetDisconnected() {
193
462
  this.#resync();
194
463
  }
464
+ /**
465
+ * Holds the stack to a changed `max`: lowering it below the count dismisses the
466
+ * oldest messages hover and focus do not hold, with reason `limit`, as an arrival
467
+ * past the cap does, while raising it or setting `0` dismisses nothing. Stimulus can
468
+ * also call this ahead of `connect()` — for an attribute that changed while the
469
+ * controller was away, and on every connect for an undeclared `max`, with its default;
470
+ * `connect()` decides then.
471
+ */
472
+ maxValueChanged() {
473
+ if (!this.#connected) return;
474
+ this.#appliedMax = this.maxValue;
475
+ this.#enforceMax();
476
+ }
195
477
  /**
196
478
  * Whether this controller owns `message`. Ownership is the current `region`'s
197
479
  * subtree: a message target anywhere else in the controller's scope is the
@@ -210,12 +492,16 @@ var FlashController = class extends Controller {
210
492
  * scheduled removal are cancelled. A move *within* the region keeps all of them —
211
493
  * which is why the element must still be a message to be treated as one: ownership
212
494
  * alone reads an in-place attribute rewrite as a move, and a node outside the target
213
- * set belongs to the consumer, so nothing here may dismiss it.
495
+ * set belongs to the consumer, so nothing here may dismiss it. A move may still have
496
+ * ended a hold, so those are read again.
214
497
  */
215
498
  messageTargetDisconnected(message) {
216
499
  if (!this.#connected) return;
217
- const moved = this.#owns(message) && message.matches(MESSAGE_SELECTOR);
218
- if (moved) return;
500
+ const moved = this.#owns(message) && message.matches(this.#messageSelector);
501
+ if (moved) {
502
+ this.#afterMove(message);
503
+ return;
504
+ }
219
505
  this.#forget(message);
220
506
  this.#leaving.delete(message);
221
507
  }
@@ -249,13 +535,11 @@ var FlashController = class extends Controller {
249
535
  this.#observer = null;
250
536
  }
251
537
  /**
252
- * Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides
253
- * stay in sync. Binding is gated by `pauseOnHover`; unbinding is unconditional
254
- * and idempotent (a no-op when nothing was bound), which keeps teardown correct
255
- * even if `pauseOnHover` were ever toggled during a message's life.
538
+ * Hover and focus listeners, bound and unbound as a pair so the two sides stay in
539
+ * sync. Every message gets them, whatever `pauseOnHover` says, because the cap reads
540
+ * the holds they record; unbinding is idempotent (a no-op when nothing was bound).
256
541
  */
257
542
  #bindPause(message) {
258
- if (!this.pauseOnHoverValue) return;
259
543
  message.addEventListener("mouseenter", this.#onEnter);
260
544
  message.addEventListener("mouseleave", this.#onLeave);
261
545
  message.addEventListener("focusin", this.#onEnter);
@@ -270,7 +554,7 @@ var FlashController = class extends Controller {
270
554
  /** Dismisses the flash whose close control fired the event. */
271
555
  dismiss(event) {
272
556
  const target = event.currentTarget || event.target;
273
- const message = target?.closest(MESSAGE_SELECTOR);
557
+ const message = target?.closest(this.#messageSelector);
274
558
  if (message) this.#beginDismiss(message, "user");
275
559
  }
276
560
  /** Processes messages added after connect (Turbo Stream); their own role announces them. */
@@ -278,21 +562,25 @@ var FlashController = class extends Controller {
278
562
  for (const mutation of mutations) {
279
563
  for (const node of mutation.addedNodes) {
280
564
  if (!(node instanceof HTMLElement)) continue;
281
- if (node.matches(MESSAGE_SELECTOR)) this.#process(node, false);
282
- for (const message of node.querySelectorAll(MESSAGE_SELECTOR)) {
565
+ if (node.matches(this.#messageSelector)) this.#process(node, false);
566
+ for (const message of node.querySelectorAll(this.#messageSelector)) {
283
567
  this.#process(message, false);
284
568
  }
285
569
  }
286
570
  }
287
571
  }
288
572
  /**
289
- * Applies role/state, wires pause listeners, schedules auto-dismiss, and either
290
- * bridges to the Announcer (`bridge`, for initial flashes) or leaves the message's
291
- * own role to do the announcing (dynamic inserts). Idempotent per message.
573
+ * Applies role/state, wires pause listeners, takes the holds the message already has,
574
+ * and schedules auto-dismiss. A message `connect()` takes on (`atConnect`) is bridged
575
+ * to the Announcer and meets no cap here, `connect()` holding the stack to it; a later
576
+ * insert announces through its own role and meets the cap as it arrives. Idempotent
577
+ * per message, and a node that has left the region by the time its insertion is
578
+ * reported is not taken on.
292
579
  */
293
- #process(message, bridge) {
294
- if (this.#state.has(message) || this.#order.includes(message)) return;
295
- if (this.#leaving.has(message)) return;
580
+ #process(message, atConnect) {
581
+ if (this.#order.includes(message) || this.#leaving.has(message) || !this.#owns(message)) {
582
+ return;
583
+ }
296
584
  const type = message.getAttribute("data-flash-type") ?? "";
297
585
  const assertive = ASSERTIVE_TYPES.has(type);
298
586
  if (!message.hasAttribute("role")) {
@@ -301,72 +589,145 @@ var FlashController = class extends Controller {
301
589
  message.setAttribute("data-flash-state", "visible");
302
590
  this.#order.push(message);
303
591
  this.#bindPause(message);
592
+ this.#readHolds(message);
304
593
  const text = message.textContent?.trim() ?? "";
305
594
  this.dispatch("show", { target: message, detail: { type, message: text } });
306
- if (bridge && text) {
307
- window.dispatchEvent(
308
- new CustomEvent("stimeo--announcer:announce", { detail: { message: text, assertive } })
309
- );
310
- }
595
+ if (atConnect) announce(text, { assertive });
311
596
  this.#startTimer(message);
312
- this.#enforceMax();
597
+ if (!atConnect) this.#enforceMax([message]);
313
598
  }
314
- /** Removes the oldest visible flashes once the count exceeds `max` (0 = unlimited). */
315
- #enforceMax() {
316
- if (this.maxValue <= 0) return;
317
- while (this.#order.length > this.maxValue) {
318
- const oldest = this.#order[0];
319
- if (!oldest) break;
320
- this.#beginDismiss(oldest, "limit");
599
+ /**
600
+ * Dismisses the oldest messages with reason `limit` while more than `max` are shown
601
+ * (0 or less, or not a finite number, means no cap), passing over every message hover
602
+ * or focus holds and those in `spared` — the message just taken on, or the ones the
603
+ * pointer or focus moved into. Only messages still in the region count or go: one a
604
+ * script has just taken out stays on the stack until its removal is reported. With
605
+ * nothing else left to go the stack stays over the cap. The loop walks a copy and
606
+ * re-reads the count on every step, because a `dismiss` listener may already have
607
+ * changed both.
608
+ *
609
+ * @stimeoRenderRoot
610
+ */
611
+ #enforceMax(spared = []) {
612
+ if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return;
613
+ for (const message of [...this.#order]) {
614
+ if (this.#shownCount() <= this.maxValue) return;
615
+ if (this.#owns(message) && !spared.includes(message) && !this.#dismiss.isHeld(message)) {
616
+ this.#beginDismiss(message, "limit");
617
+ }
321
618
  }
322
619
  }
323
- #startTimer(message, duration = this.durationValue) {
324
- if (duration <= 0) return;
325
- const existing = this.#state.get(message);
326
- if (existing?.id) this.#timers.clear(existing.id);
327
- const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
328
- this.#state.set(message, {
329
- id,
330
- startedAt: Date.now(),
331
- remaining: duration,
332
- paused: existing?.paused ?? /* @__PURE__ */ new Set()
333
- });
620
+ /** How many messages the stack shows: the ones taken on that are still in the region. */
621
+ #shownCount() {
622
+ return this.#order.filter((message) => this.#owns(message)).length;
623
+ }
624
+ /**
625
+ * Arms a message's auto-dismiss; a non-positive `duration` means it never expires.
626
+ * The timer waits out hover and focus when `pauseOnHover` is on, and runs on
627
+ * regardless when it is off.
628
+ *
629
+ * @stimeoRuntimeOnly `duration` is the delay of the one dismissal timer this call arms,
630
+ * and `pauseOnHover` whether hover and focus hold that timer.
631
+ */
632
+ #startTimer(message) {
633
+ if (this.durationValue <= 0) return;
634
+ const dismiss = () => this.#beginDismiss(message, "timeout");
635
+ if (this.pauseOnHoverValue) this.#dismiss.set(message, dismiss, this.durationValue);
636
+ else this.#fixedDismiss.set(message, dismiss, this.durationValue);
334
637
  }
335
638
  /**
336
- * Pauses a message's auto-dismiss, banking the time left (hover/focus, WCAG 2.2.1).
337
- * Hover and focus are independent reasons: the remaining time is banked on the
338
- * first of them, and {@link FlashController.#resume} waits for the last one.
639
+ * Records hover or focus on a message. Hover and focus are independent reasons:
640
+ * the registry banks the time left on the first of them and waits for the last
641
+ * (WCAG 2.2 2.2.1), and the cap passes over the message while either is held.
339
642
  */
340
643
  #pause(message, reason) {
341
- const timer = this.#state.get(message);
342
- if (!timer) return;
343
- timer.paused.add(reason);
344
- if (timer.id === 0) return;
345
- this.#timers.clear(timer.id);
346
- const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
347
- this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
348
- }
349
- /** Resumes a paused message's auto-dismiss with the banked time. */
350
- #resume(message, reason) {
351
- const timer = this.#state.get(message);
352
- if (!timer) return;
353
- timer.paused.delete(reason);
354
- if (timer.paused.size > 0) return;
355
- if (timer.id !== 0) return;
356
- this.#startTimer(message, timer.remaining);
357
- }
358
- /** Releases every per-message resource: timer, stacking slot, pause listeners. */
644
+ this.#dismiss.pause(message, reason);
645
+ }
646
+ /**
647
+ * Releases one reason, resuming the banked time once no reason is left. Releasing
648
+ * the last one applies the cap again, sparing `spare`.
649
+ */
650
+ #resume(message, reason, spare) {
651
+ const held = this.#dismiss.isHeld(message);
652
+ this.#dismiss.resume(message, reason);
653
+ if (held && !this.#dismiss.isHeld(message)) this.#capLater(spare);
654
+ }
655
+ /**
656
+ * Applies the cap again once the current event is over, sparing `spare`. A hold can
657
+ * end in a `focusout` the engine fires while it is itself taking the node out for a
658
+ * script's move or removal, and dismissing the message inside that event would pull
659
+ * the node from under the caller's own call.
660
+ */
661
+ #capLater(spare) {
662
+ if (spare) this.#spared.push(spare);
663
+ this.#reapply.schedule();
664
+ }
665
+ /** Applies the cap again, sparing every message gathered since the last pass. */
666
+ #reapplyCap() {
667
+ const spared = this.#spared;
668
+ this.#spared = [];
669
+ this.#enforceMax(spared);
670
+ }
671
+ /**
672
+ * Reads the holds of a message that moved within the region. Focus is read at once:
673
+ * an ordinary move ends it with a `focusout` and `moveBefore` keeps it. Where the
674
+ * pointer is, nothing says until it moves again: no `mouseleave` reaches a moved node,
675
+ * and `:hover` can answer from before the move. A message still held waits for that
676
+ * movement.
677
+ */
678
+ #afterMove(message) {
679
+ if (!message.contains(document.activeElement)) this.#resume(message, "focus", null);
680
+ if (this.#dismiss.isHeld(message)) this.#awaitPointer(message);
681
+ }
682
+ /**
683
+ * Takes the holds a message already has as it is taken on: focus inside it, and the
684
+ * pointer over it by `:hover`. That answer can date from before the message came here,
685
+ * so a hover hold taken on it waits for the next pointer movement to be confirmed.
686
+ */
687
+ #readHolds(message) {
688
+ if (message.contains(document.activeElement)) this.#pause(message, "focus");
689
+ if (!message.matches(":hover")) return;
690
+ this.#pause(message, "hover");
691
+ this.#awaitPointer(message);
692
+ }
693
+ /** Leaves `message`'s hover hold to be confirmed or let go on the next pointer movement. */
694
+ #awaitPointer(message) {
695
+ this.#awaitingPointer.add(message);
696
+ this.#pointer.add(document, "pointermove", this.#onPointerMove, {
697
+ capture: true,
698
+ passive: true
699
+ });
700
+ }
701
+ /**
702
+ * Reads `:hover` once the pointer has moved, when the answer is current, and lets go of
703
+ * the hover hold of every waiting message the pointer is not over. Listens once.
704
+ */
705
+ #onPointerMove = () => {
706
+ this.#pointer.dispose();
707
+ const waiting = [...this.#awaitingPointer];
708
+ this.#awaitingPointer.clear();
709
+ for (const message of waiting) {
710
+ if (!message.matches(":hover")) this.#resume(message, "hover", null);
711
+ }
712
+ };
713
+ /**
714
+ * Releases every per-message resource: timers, hold, stacking slot, pause listeners,
715
+ * and the wait on the pointer. A message something still held counts as released: the
716
+ * cap is applied again.
717
+ */
359
718
  #forget(message) {
360
- const timer = this.#state.get(message);
361
- if (timer?.id) this.#timers.clear(timer.id);
362
- this.#state.delete(message);
719
+ if (this.#dismiss.isHeld(message)) this.#capLater(null);
720
+ this.#dismiss.clear(message);
721
+ this.#fixedDismiss.clear(message);
363
722
  const index = this.#order.indexOf(message);
364
723
  if (index !== -1) this.#order.splice(index, 1);
365
724
  this.#unbindPause(message);
725
+ this.#awaitingPointer.delete(message);
726
+ if (this.#awaitingPointer.size === 0) this.#pointer.dispose();
366
727
  }
367
728
  /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
368
729
  #beginDismiss(message, reason) {
369
- if (!this.#state.has(message) && !this.#order.includes(message)) return;
730
+ if (!this.#order.includes(message)) return;
370
731
  this.#forget(message);
371
732
  this.#leaving.add(message);
372
733
  message.setAttribute("data-flash-state", "leaving");
@@ -437,6 +437,7 @@ var FocusController = class extends Controller {
437
437
  this.element.setAttribute("data-focus-trapped", "true");
438
438
  this.dispatch("activate", { detail: {} });
439
439
  }
440
+ /** @stimeoRuntimeOnly `restore` decides whether releasing this one trap returns focus. */
440
441
  #deactivate() {
441
442
  if (!this.#active) return;
442
443
  this.#active = false;
@@ -141,15 +141,17 @@ var MicrotaskCoalescer = class {
141
141
 
142
142
  // src/controllers/form_field_controller.ts
143
143
  var OBSERVED_ATTRIBUTES = ["hidden", "id"];
144
- var FormFieldController = class _FormFieldController extends Controller {
144
+ var FormFieldController = class extends Controller {
145
145
  static targets = ["control", "description", "error"];
146
146
  static values = {
147
147
  focusOnError: { type: Boolean, default: false }
148
148
  };
149
149
  static actions = ["clearError", "setError"];
150
150
  static events = ["validate"];
151
- /** Root attribute (CSS hook) reflecting the invalid state. */
152
- static #INVALID_ATTR = "data-stimeo--form-field-invalid";
151
+ /** Root attribute (CSS hook) reflecting the invalid state, in this controller's namespace. */
152
+ get #invalidAttribute() {
153
+ return `data-${this.identifier}-invalid`;
154
+ }
153
155
  /** Collapses one target/morph batch into one silent ARIA reconciliation. */
154
156
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
155
157
  /** Returns borrowed control ARIA before Turbo snapshots the page. */
@@ -173,7 +175,7 @@ var FormFieldController = class _FormFieldController extends Controller {
173
175
  this.#ensureAssociationIds();
174
176
  this.#beforeCache.activate();
175
177
  if (!this.#initialized) {
176
- this.#explicitInvalid = this.element.hasAttribute(_FormFieldController.#INVALID_ATTR) && this.#shownErrors().length === 0;
178
+ this.#explicitInvalid = this.element.hasAttribute(this.#invalidAttribute) && this.#shownErrors().length === 0;
177
179
  this.#initialized = true;
178
180
  }
179
181
  this.#reconcileDom();
@@ -270,7 +272,7 @@ var FormFieldController = class _FormFieldController extends Controller {
270
272
  this.#adoptCurrentControl();
271
273
  const shown = this.#shownErrors();
272
274
  const invalid = this.#explicitInvalid || shown.length > 0;
273
- this.element.toggleAttribute(_FormFieldController.#INVALID_ATTR, invalid);
275
+ this.element.toggleAttribute(this.#invalidAttribute, invalid);
274
276
  const control = this.#activeControl;
275
277
  if (!control) return;
276
278
  this.#ariaInvalid.write(control, invalid ? "true" : "false");