stimeo-ui 0.15.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +63 -15
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +8 -1
  7. data/dist/controllers/auto_submit_controller.js +43 -2
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +9 -8
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +323 -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 +54 -10
  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 +56 -11
  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 +205 -58
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +139 -27
  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 +14 -1
  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 +305 -46
  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 +16 -2
  41. data/dist/controllers/highlight_controller.js +19 -2
  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/masonry_controller.js +0 -2
  49. data/dist/controllers/menu_controller.js +45 -16
  50. data/dist/controllers/menubar_controller.js +58 -24
  51. data/dist/controllers/meter_controller.js +9 -5
  52. data/dist/controllers/multi_select_controller.js +278 -104
  53. data/dist/controllers/navigation_menu_controller.js +48 -15
  54. data/dist/controllers/nested_form_controller.js +37 -8
  55. data/dist/controllers/network_status_controller.js +9 -1
  56. data/dist/controllers/number_input_controller.js +124 -21
  57. data/dist/controllers/optimistic_controller.js +14 -1
  58. data/dist/controllers/otp_controller.js +198 -55
  59. data/dist/controllers/overflow_indicator_controller.js +84 -13
  60. data/dist/controllers/overflow_menu_controller.js +112 -41
  61. data/dist/controllers/pagination_controller.js +74 -28
  62. data/dist/controllers/password_reveal_controller.js +59 -2
  63. data/dist/controllers/persist_controller.js +30 -8
  64. data/dist/controllers/pointer_drag_controller.js +131 -52
  65. data/dist/controllers/popover_controller.js +45 -11
  66. data/dist/controllers/portal_controller.js +6 -2
  67. data/dist/controllers/preview_guard_controller.js +16 -1
  68. data/dist/controllers/progress_controller.js +8 -4
  69. data/dist/controllers/radio_group_controller.js +42 -17
  70. data/dist/controllers/range_slider_controller.js +88 -42
  71. data/dist/controllers/rating_controller.js +39 -15
  72. data/dist/controllers/read_more_controller.js +69 -2
  73. data/dist/controllers/resizable_controller.js +82 -22
  74. data/dist/controllers/scroll_area_controller.js +5 -1
  75. data/dist/controllers/scroll_visibility_controller.js +0 -1
  76. data/dist/controllers/scrollspy_controller.js +6 -0
  77. data/dist/controllers/separator_controller.js +66 -37
  78. data/dist/controllers/sidebar_controller.js +77 -18
  79. data/dist/controllers/skeleton_controller.js +6 -1
  80. data/dist/controllers/slider_controller.js +82 -47
  81. data/dist/controllers/smart_sticky_header_controller.js +11 -1
  82. data/dist/controllers/sortable_controller.js +17 -2
  83. data/dist/controllers/spinner_controller.js +10 -2
  84. data/dist/controllers/step_indicator_controller.js +18 -17
  85. data/dist/controllers/stepper_controller.js +101 -19
  86. data/dist/controllers/stick_to_bottom_controller.js +75 -5
  87. data/dist/controllers/submit_once_controller.js +16 -3
  88. data/dist/controllers/switch_controller.js +101 -10
  89. data/dist/controllers/tabs_controller.js +21 -2
  90. data/dist/controllers/tags_input_controller.js +209 -59
  91. data/dist/controllers/theme_controller.js +64 -14
  92. data/dist/controllers/time_picker_controller.js +23 -8
  93. data/dist/controllers/toast_controller.js +337 -54
  94. data/dist/controllers/toggle_group_controller.js +159 -23
  95. data/dist/controllers/toolbar_controller.js +32 -0
  96. data/dist/controllers/tooltip_controller.js +39 -13
  97. data/dist/controllers/transition_controller.js +4 -0
  98. data/dist/controllers/tree_view_controller.js +169 -16
  99. data/dist/index.js +4588 -1535
  100. data/dist/positioning/index.js +2 -0
  101. data/lib/stimeo/ui/version.rb +1 -1
  102. metadata +2 -2
@@ -42,6 +42,15 @@ var BeforeCacheReset = class _BeforeCacheReset {
42
42
  }
43
43
  };
44
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
+
45
54
  // src/utils/safe_timeout.ts
46
55
  var TimerRegistry = class {
47
56
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -138,6 +147,63 @@ var KeyedTimers = class {
138
147
  }
139
148
  };
140
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
+
141
207
  // src/utils/pausable_timers.ts
142
208
  var PausableTimers = class {
143
209
  #timers = new KeyedTimers();
@@ -151,16 +217,20 @@ var PausableTimers = class {
151
217
  const reasons = this.#entries.get(key)?.reasons ?? /* @__PURE__ */ new Set();
152
218
  const entry = { callback, startedAt: 0, remaining: delay, reasons };
153
219
  this.#entries.set(key, entry);
154
- if (reasons.size === 0) this.#arm(key, entry);
220
+ if (reasons.size === 0) this.#arm(key, entry, callback);
155
221
  }
156
222
  /**
157
223
  * Holds `key` for `reason` and banks the time left, with a floor of one
158
- * millisecond. Reports whether this call is the one that stopped a running
159
- * timer, so the caller can write its held-state hook exactly once.
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.
160
227
  */
161
228
  pause(key, reason) {
162
- const entry = this.#entries.get(key);
163
- if (entry === void 0) return false;
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
+ }
164
234
  entry.reasons.add(reason);
165
235
  if (!this.#timers.has(key)) return false;
166
236
  this.#timers.clear(key);
@@ -169,20 +239,40 @@ var PausableTimers = class {
169
239
  }
170
240
  /**
171
241
  * Releases `reason` on `key`, arming the banked time again once no reason is
172
- * left. Reports whether this call is the one that started the timer again.
242
+ * left; a key released with no timer is forgotten. Reports whether this call
243
+ * is the one that started the timer again.
173
244
  */
174
245
  resume(key, reason) {
175
246
  const entry = this.#entries.get(key);
176
247
  if (entry === void 0) return false;
177
248
  entry.reasons.delete(reason);
178
249
  if (entry.reasons.size > 0 || this.#timers.has(key)) return false;
179
- this.#arm(key, entry);
250
+ const { callback } = entry;
251
+ if (callback === null) {
252
+ this.#entries.delete(key);
253
+ return false;
254
+ }
255
+ this.#arm(key, entry, callback);
180
256
  return true;
181
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
+ }
182
262
  /** Whether `key` is armed or held — that is, whether this registry drives it at all. */
183
263
  tracks(key) {
184
264
  return this.#entries.has(key);
185
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
+ }
186
276
  /** Cancels `key`'s timer and drops its hold. */
187
277
  clear(key) {
188
278
  this.#timers.clear(key);
@@ -196,20 +286,25 @@ var PausableTimers = class {
196
286
  this.#timers.clearAll();
197
287
  this.#entries.clear();
198
288
  }
199
- /** Starts `entry`'s banked time running for `key`, and drops it as it fires. */
200
- #arm(key, entry) {
289
+ /** Starts `entry`'s banked time running for `key`, and drops it as `callback` fires. */
290
+ #arm(key, entry, callback) {
201
291
  entry.startedAt = Date.now();
202
292
  this.#timers.set(
203
293
  key,
204
294
  () => {
205
295
  this.#entries.delete(key);
206
- entry.callback();
296
+ callback();
207
297
  },
208
298
  entry.remaining
209
299
  );
210
300
  }
211
301
  };
212
302
 
303
+ // src/utils/target_selector.ts
304
+ function targetSelector(identifier, name) {
305
+ return `[data-${identifier}-target~="${name}"]`;
306
+ }
307
+
213
308
  // src/utils/transition_completion.ts
214
309
  function timeMs(value) {
215
310
  const trimmed = value.trim();
@@ -246,8 +341,12 @@ function maxTransitionTotalMs(style) {
246
341
 
247
342
  // src/controllers/flash_controller.ts
248
343
  var ASSERTIVE_TYPES = /* @__PURE__ */ new Set(["alert", "error"]);
249
- var MESSAGE_SELECTOR = '[data-stimeo--flash-target="message"]';
344
+ var MESSAGE_PART = "message";
250
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
+ }
251
350
  static targets = ["region", "message"];
252
351
  static values = {
253
352
  duration: { type: Number, default: 5e3 },
@@ -258,11 +357,33 @@ var FlashController = class extends Controller {
258
357
  static events = ["show", "dismiss", "reconcile"];
259
358
  /** Removal timers for the leaving transition; the auto-dismiss ones live below. */
260
359
  #timers = new SafeTimeout();
261
- /** Per-message auto-dismiss, held open while the message is hovered or focused. */
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
+ */
262
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();
263
378
  #observer = null;
264
379
  /** Whether the controller is between `connect()` and `disconnect()`. */
265
380
  #connected = false;
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;
266
387
  /** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
267
388
  #order = [];
268
389
  /**
@@ -274,12 +395,28 @@ var FlashController = class extends Controller {
274
395
  #leaving = /* @__PURE__ */ new Set();
275
396
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
276
397
  #onEnter = (event) => this.#pause(event.currentTarget, event.type === "focusin" ? "focus" : "hover");
277
- #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
+ };
278
410
  connect() {
279
411
  this.#connected = true;
412
+ this.#reapply.activate();
280
413
  for (const message of this.messageTargets) {
281
414
  if (this.#owns(message)) this.#process(message, true);
282
415
  }
416
+ if (!Object.is(this.#appliedMax, this.maxValue)) {
417
+ this.#appliedMax = this.maxValue;
418
+ this.#enforceMax();
419
+ }
283
420
  this.#syncObservation();
284
421
  this.#beforeCache.activate();
285
422
  }
@@ -289,6 +426,11 @@ var FlashController = class extends Controller {
289
426
  this.#stopObserving();
290
427
  this.#timers.clearAll();
291
428
  this.#dismiss.clearAll();
429
+ this.#fixedDismiss.clearAll();
430
+ this.#reapply.cancel();
431
+ this.#spared = [];
432
+ this.#pointer.dispose();
433
+ this.#awaitingPointer.clear();
292
434
  for (const message of this.#order) this.#unbindPause(message);
293
435
  this.#order.length = 0;
294
436
  this.#leaving.clear();
@@ -319,6 +461,19 @@ var FlashController = class extends Controller {
319
461
  regionTargetDisconnected() {
320
462
  this.#resync();
321
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
+ }
322
477
  /**
323
478
  * Whether this controller owns `message`. Ownership is the current `region`'s
324
479
  * subtree: a message target anywhere else in the controller's scope is the
@@ -337,12 +492,16 @@ var FlashController = class extends Controller {
337
492
  * scheduled removal are cancelled. A move *within* the region keeps all of them —
338
493
  * which is why the element must still be a message to be treated as one: ownership
339
494
  * alone reads an in-place attribute rewrite as a move, and a node outside the target
340
- * 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.
341
497
  */
342
498
  messageTargetDisconnected(message) {
343
499
  if (!this.#connected) return;
344
- const moved = this.#owns(message) && message.matches(MESSAGE_SELECTOR);
345
- if (moved) return;
500
+ const moved = this.#owns(message) && message.matches(this.#messageSelector);
501
+ if (moved) {
502
+ this.#afterMove(message);
503
+ return;
504
+ }
346
505
  this.#forget(message);
347
506
  this.#leaving.delete(message);
348
507
  }
@@ -376,13 +535,11 @@ var FlashController = class extends Controller {
376
535
  this.#observer = null;
377
536
  }
378
537
  /**
379
- * Pause-on-hover/focus listeners, bound and unbound as a pair so the two sides
380
- * stay in sync. Binding is gated by `pauseOnHover`; unbinding is unconditional
381
- * and idempotent (a no-op when nothing was bound), which keeps teardown correct
382
- * 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).
383
541
  */
384
542
  #bindPause(message) {
385
- if (!this.pauseOnHoverValue) return;
386
543
  message.addEventListener("mouseenter", this.#onEnter);
387
544
  message.addEventListener("mouseleave", this.#onLeave);
388
545
  message.addEventListener("focusin", this.#onEnter);
@@ -397,7 +554,7 @@ var FlashController = class extends Controller {
397
554
  /** Dismisses the flash whose close control fired the event. */
398
555
  dismiss(event) {
399
556
  const target = event.currentTarget || event.target;
400
- const message = target?.closest(MESSAGE_SELECTOR);
557
+ const message = target?.closest(this.#messageSelector);
401
558
  if (message) this.#beginDismiss(message, "user");
402
559
  }
403
560
  /** Processes messages added after connect (Turbo Stream); their own role announces them. */
@@ -405,20 +562,25 @@ var FlashController = class extends Controller {
405
562
  for (const mutation of mutations) {
406
563
  for (const node of mutation.addedNodes) {
407
564
  if (!(node instanceof HTMLElement)) continue;
408
- if (node.matches(MESSAGE_SELECTOR)) this.#process(node, false);
409
- 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)) {
410
567
  this.#process(message, false);
411
568
  }
412
569
  }
413
570
  }
414
571
  }
415
572
  /**
416
- * Applies role/state, wires pause listeners, schedules auto-dismiss, and either
417
- * bridges to the Announcer (`bridge`, for initial flashes) or leaves the message's
418
- * 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.
419
579
  */
420
- #process(message, bridge) {
421
- if (this.#order.includes(message) || 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
+ }
422
584
  const type = message.getAttribute("data-flash-type") ?? "";
423
585
  const assertive = ASSERTIVE_TYPES.has(type);
424
586
  if (!message.hasAttribute("role")) {
@@ -427,44 +589,141 @@ var FlashController = class extends Controller {
427
589
  message.setAttribute("data-flash-state", "visible");
428
590
  this.#order.push(message);
429
591
  this.#bindPause(message);
592
+ this.#readHolds(message);
430
593
  const text = message.textContent?.trim() ?? "";
431
594
  this.dispatch("show", { target: message, detail: { type, message: text } });
432
- if (bridge) announce(text, { assertive });
595
+ if (atConnect) announce(text, { assertive });
433
596
  this.#startTimer(message);
434
- this.#enforceMax();
597
+ if (!atConnect) this.#enforceMax([message]);
435
598
  }
436
- /** Removes the oldest visible flashes once the count exceeds `max` (0 = unlimited). */
437
- #enforceMax() {
438
- if (this.maxValue <= 0) return;
439
- while (this.#order.length > this.maxValue) {
440
- const oldest = this.#order[0];
441
- if (!oldest) break;
442
- 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
+ }
443
618
  }
444
619
  }
445
- /** Arms a message's auto-dismiss; a non-positive `duration` means it never expires. */
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
+ */
446
632
  #startTimer(message) {
447
633
  if (this.durationValue <= 0) return;
448
- this.#dismiss.set(message, () => this.#beginDismiss(message, "timeout"), this.durationValue);
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);
449
637
  }
450
638
  /**
451
- * Holds a message's auto-dismiss open while it is hovered or focused
452
- * (WCAG 2.2 2.2.1). Hover and focus are independent reasons, and the registry
453
- * banks the time left on the first of them and waits for the last.
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.
454
642
  */
455
643
  #pause(message, reason) {
456
644
  this.#dismiss.pause(message, reason);
457
645
  }
458
- /** Releases one reason, resuming the banked time once no reason is left. */
459
- #resume(message, reason) {
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);
460
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);
461
681
  }
462
- /** Releases every per-message resource: timer, stacking slot, pause listeners. */
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
+ */
463
718
  #forget(message) {
719
+ if (this.#dismiss.isHeld(message)) this.#capLater(null);
464
720
  this.#dismiss.clear(message);
721
+ this.#fixedDismiss.clear(message);
465
722
  const index = this.#order.indexOf(message);
466
723
  if (index !== -1) this.#order.splice(index, 1);
467
724
  this.#unbindPause(message);
725
+ this.#awaitingPointer.delete(message);
726
+ if (this.#awaitingPointer.size === 0) this.#pointer.dispose();
468
727
  }
469
728
  /** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
470
729
  #beginDismiss(message, reason) {
@@ -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");
@@ -96,13 +96,19 @@ var CONSTRAINT_MESSAGE_KEYS = [
96
96
  ["stepMismatch", "step-mismatch"],
97
97
  ["badInput", "bad-input"]
98
98
  ];
99
- var MESSAGE_ATTR_PREFIX = "data-stimeo--form-validation-message-";
100
- var MESSAGE_ATTR_GENERIC = "data-stimeo--form-validation-message";
101
- var DISALLOW_ATTR = "data-stimeo--form-validation-disallow";
102
- var DISALLOW_WHITESPACE_MESSAGE = `${MESSAGE_ATTR_PREFIX}whitespace`;
99
+ var MESSAGE_PART = "message";
100
+ var DISALLOW_PART = "disallow";
103
101
  var DISALLOW_WHITESPACE_DEFAULT = "Please enter a value that is not only whitespace.";
104
102
  var FORM_FIELD_INVALID_ATTR = "data-stimeo--form-field-invalid";
105
103
  var FormValidationController = class _FormValidationController extends Controller {
104
+ /** The generic message override, in the namespace this controller is registered under. */
105
+ get #messageAttribute() {
106
+ return `data-${this.identifier}-${MESSAGE_PART}`;
107
+ }
108
+ /** Marks the `novalidate` this controller applied, so an authored one is left alone. */
109
+ get #novalidateMarker() {
110
+ return `data-${this.identifier}-${_FormValidationController.#NOVALIDATE_PART}`;
111
+ }
106
112
  static outlets = ["stimeo--form-field"];
107
113
  static values = {
108
114
  validateOnBlur: { type: Boolean, default: true },
@@ -112,8 +118,8 @@ var FormValidationController = class _FormValidationController extends Controlle
112
118
  };
113
119
  static actions = ["validate"];
114
120
  static events = ["valid", "invalid"];
115
- /** Marker recording that we added `novalidate`, so we only remove our own. */
116
- static #NOVALIDATE_MARKER = "data-stimeo--form-validation-novalidate";
121
+ /** Suffix of the marker recording that we added `novalidate`, so we only remove our own. */
122
+ static #NOVALIDATE_PART = "novalidate";
117
123
  /** Object-backed groups already interacted with — the input revalidation gate. */
118
124
  #touchedGroups = /* @__PURE__ */ new WeakSet();
119
125
  /** String-backed fallback radio groups already interacted with. */
@@ -169,7 +175,7 @@ var FormValidationController = class _FormValidationController extends Controlle
169
175
  /** Suppresses native bubbles and binds the submit / blur / input listeners. */
170
176
  connect() {
171
177
  if (setDefaultAttribute(this.element, "novalidate", "")) {
172
- this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
178
+ this.element.setAttribute(this.#novalidateMarker, "");
173
179
  }
174
180
  this.#listenerDocument = this.element.ownerDocument;
175
181
  this.#listenerDocument.addEventListener("submit", this.#onSubmit, true);
@@ -189,9 +195,9 @@ var FormValidationController = class _FormValidationController extends Controlle
189
195
  }
190
196
  this.#ownedCustomErrors.clear();
191
197
  this.#touchedRadioGroups.clear();
192
- if (this.element.hasAttribute(_FormValidationController.#NOVALIDATE_MARKER)) {
198
+ if (this.element.hasAttribute(this.#novalidateMarker)) {
193
199
  this.element.removeAttribute("novalidate");
194
- this.element.removeAttribute(_FormValidationController.#NOVALIDATE_MARKER);
200
+ this.element.removeAttribute(this.#novalidateMarker);
195
201
  }
196
202
  }
197
203
  /**
@@ -283,10 +289,10 @@ var FormValidationController = class _FormValidationController extends Controlle
283
289
  if (control.validity.customError) return control.validationMessage;
284
290
  for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {
285
291
  if (control.validity[flag]) {
286
- return this.#authoredMessage(control, `${MESSAGE_ATTR_PREFIX}${key}`) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? control.validationMessage;
292
+ return this.#authoredMessage(control, `${this.#messageAttribute}-${key}`) ?? this.#authoredMessage(control, this.#messageAttribute) ?? control.validationMessage;
287
293
  }
288
294
  }
289
- return control.validationMessage || this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) || "";
295
+ return control.validationMessage || this.#authoredMessage(control, this.#messageAttribute) || "";
290
296
  }
291
297
  /**
292
298
  * Applies (or clears) a declarative custom constraint via `setCustomValidity`,
@@ -302,14 +308,14 @@ var FormValidationController = class _FormValidationController extends Controlle
302
308
  #syncCustomValidity(control) {
303
309
  const recorded = this.#ownedCustomErrors.get(control);
304
310
  const ownsCurrent = recorded !== void 0 && control.validationMessage === recorded;
305
- const violates = control.getAttribute(DISALLOW_ATTR) === "whitespace" && control.value.length > 0 && control.value.trim() === "";
311
+ const violates = control.getAttribute(`data-${this.identifier}-${DISALLOW_PART}`) === "whitespace" && control.value.length > 0 && control.value.trim() === "";
306
312
  if (!violates) {
307
313
  if (ownsCurrent) control.setCustomValidity("");
308
314
  this.#ownedCustomErrors.delete(control);
309
315
  return;
310
316
  }
311
317
  if (control.validity.customError && !ownsCurrent) return;
312
- const message = this.#authoredMessage(control, DISALLOW_WHITESPACE_MESSAGE) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? DISALLOW_WHITESPACE_DEFAULT;
318
+ const message = this.#authoredMessage(control, `${this.#messageAttribute}-whitespace`) ?? this.#authoredMessage(control, this.#messageAttribute) ?? DISALLOW_WHITESPACE_DEFAULT;
313
319
  if (!ownsCurrent || recorded !== message) control.setCustomValidity(message);
314
320
  this.#ownedCustomErrors.set(control, message);
315
321
  }