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
@@ -31,39 +31,6 @@ var BeforeCacheReset = class _BeforeCacheReset {
31
31
  }
32
32
  };
33
33
 
34
- // src/utils/microtask_coalescer.ts
35
- var MicrotaskCoalescer = class {
36
- #run;
37
- #queued = false;
38
- #active = false;
39
- #generation = 0;
40
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
41
- constructor(run) {
42
- this.#run = run;
43
- }
44
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
45
- activate() {
46
- this.#active = true;
47
- }
48
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
49
- cancel() {
50
- this.#active = false;
51
- this.#queued = false;
52
- this.#generation += 1;
53
- }
54
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
55
- schedule() {
56
- if (!this.#active || this.#queued) return;
57
- this.#queued = true;
58
- const generation = this.#generation;
59
- queueMicrotask(() => {
60
- if (generation !== this.#generation || !this.#queued || !this.#active) return;
61
- this.#queued = false;
62
- this.#run();
63
- });
64
- }
65
- };
66
-
67
34
  // src/utils/safe_timeout.ts
68
35
  var TimerRegistry = class {
69
36
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -117,6 +84,82 @@ var SafeTimeout = class extends TimerRegistry {
117
84
  }
118
85
  };
119
86
 
87
+ // src/utils/keyed_timers.ts
88
+ var KeyedTimers = class {
89
+ #timers = new SafeTimeout();
90
+ /** The pending timer of each key; an entry lives exactly as long as its timer. */
91
+ #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
+ set(key, callback, delay) {
98
+ this.clear(key);
99
+ const id = this.#timers.set(() => {
100
+ this.#pending.delete(key);
101
+ callback();
102
+ }, delay);
103
+ this.#pending.set(key, id);
104
+ }
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
+ clear(key) {
113
+ this.#timers.clear(this.#pending.get(key) ?? -1);
114
+ this.#pending.delete(key);
115
+ }
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
+ clearAll() {
121
+ this.#timers.clearAll();
122
+ this.#pending.clear();
123
+ }
124
+ /** Whether `key` has a timer pending. */
125
+ has(key) {
126
+ return this.#pending.has(key);
127
+ }
128
+ };
129
+
130
+ // src/utils/microtask_coalescer.ts
131
+ var MicrotaskCoalescer = class {
132
+ #run;
133
+ #queued = false;
134
+ #active = false;
135
+ #generation = 0;
136
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
137
+ constructor(run) {
138
+ this.#run = run;
139
+ }
140
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
141
+ activate() {
142
+ this.#active = true;
143
+ }
144
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
145
+ cancel() {
146
+ this.#active = false;
147
+ this.#queued = false;
148
+ this.#generation += 1;
149
+ }
150
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
151
+ schedule() {
152
+ if (!this.#active || this.#queued) return;
153
+ this.#queued = true;
154
+ const generation = this.#generation;
155
+ queueMicrotask(() => {
156
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
157
+ this.#queued = false;
158
+ this.#run();
159
+ });
160
+ }
161
+ };
162
+
120
163
  // src/controllers/announcer_controller.ts
121
164
  var LEVELS = ["polite", "assertive"];
122
165
  var AnnouncerController = class extends Controller {
@@ -126,8 +169,14 @@ var AnnouncerController = class extends Controller {
126
169
  dedupeReannounce: { type: Boolean, default: true }
127
170
  };
128
171
  static actions = ["announce"];
129
- /** Clear/re-announce timers; one `clearAll()` in disconnect tears them all down. */
172
+ /** Drain timers, which belong to a politeness level rather than to a region. */
130
173
  #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
+ */
179
+ #regionTimers = new KeyedTimers();
131
180
  /** Live regions generated to stand in for absent targets, for teardown. */
132
181
  #generated = /* @__PURE__ */ new Map();
133
182
  /** Messages waiting to be written, oldest first, one queue per politeness. */
@@ -147,13 +196,6 @@ var AnnouncerController = class extends Controller {
147
196
  });
148
197
  /** Rewinds to an announceable initial state for the snapshot; see the remarks. */
149
198
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
150
- /**
151
- * The one timer a region may have outstanding — its dedupe re-set, then its
152
- * auto-clear. Held weakly so a swapped-out target is not retained; a leftover
153
- * id is harmless because {@link SafeTimeout.clear} no-ops on an id it does not
154
- * own.
155
- */
156
- #pending = /* @__PURE__ */ new WeakMap();
157
199
  /**
158
200
  * Guards against handling the same CustomEvent twice. An event dispatched on
159
201
  * the controller element with `bubbles: true` reaches both the element and the
@@ -184,6 +226,7 @@ var AnnouncerController = class extends Controller {
184
226
  this.element.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
185
227
  window.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
186
228
  this.#timers.clearAll();
229
+ this.#regionTimers.clearAll();
187
230
  this.#queues.clear();
188
231
  this.#draining.clear();
189
232
  this.#removeGenerated();
@@ -303,6 +346,9 @@ var AnnouncerController = class extends Controller {
303
346
  * region (it has to be in the accessibility tree before the text arrives) and
304
347
  * emptying a region that already holds this exact text (an unchanged node is not
305
348
  * re-read, so `dedupeReannounce` clears first and writes on the following pass).
349
+ *
350
+ * @stimeoRuntimeOnly `dedupeReannounce` decides whether one queued message is spoken again; the
351
+ * region's text is the message itself.
306
352
  */
307
353
  #drain(level) {
308
354
  const queue = this.#queues.get(level);
@@ -314,21 +360,25 @@ var AnnouncerController = class extends Controller {
314
360
  }
315
361
  const region = this.#regionFor(level);
316
362
  if (this.dedupeReannounceValue && region.textContent === message) {
317
- this.#cancelPending(region);
363
+ this.#regionTimers.clear(region);
318
364
  region.textContent = "";
319
365
  this.#scheduleDrain(level);
320
366
  return;
321
367
  }
322
368
  queue.shift();
323
- this.#cancelPending(region);
369
+ this.#regionTimers.clear(region);
324
370
  region.textContent = message;
325
371
  this.#scheduleClear(region, message);
326
372
  if (queue.length > 0) this.#scheduleDrain(level);
327
373
  }
328
- /** Clears the region after `clearAfter` ms, unless a newer message replaced it. */
374
+ /**
375
+ * Clears the region after `clearAfter` ms, unless a newer message replaced it.
376
+ *
377
+ * @stimeoRuntimeOnly `clearAfter` is the delay of the one clearing timer this call arms.
378
+ */
329
379
  #scheduleClear(region, message) {
330
380
  if (this.clearAfterValue <= 0) return;
331
- this.#schedule(
381
+ this.#regionTimers.set(
332
382
  region,
333
383
  () => {
334
384
  if (region.textContent === message) region.textContent = "";
@@ -336,23 +386,6 @@ var AnnouncerController = class extends Controller {
336
386
  this.clearAfterValue
337
387
  );
338
388
  }
339
- /**
340
- * Arms `region`'s single pending timer. Callers reach here with the slot
341
- * already free — `#drain` releases it before writing, and a fired timer clears its own
342
- * entry below — so this does not cancel again.
343
- */
344
- #schedule(region, callback, delay) {
345
- const id = this.#timers.set(() => {
346
- this.#pending.delete(region);
347
- callback();
348
- }, delay);
349
- this.#pending.set(region, id);
350
- }
351
- /** Releases `region`'s pending timer, if it has one. */
352
- #cancelPending(region) {
353
- this.#timers.clear(this.#pending.get(region) ?? -1);
354
- this.#pending.delete(region);
355
- }
356
389
  /**
357
390
  * Resolves the live region for a politeness level.
358
391
  *
@@ -378,6 +411,7 @@ var AnnouncerController = class extends Controller {
378
411
  this.#queues.clear();
379
412
  this.#draining.clear();
380
413
  this.#timers.clearAll();
414
+ this.#regionTimers.clearAll();
381
415
  for (const level of LEVELS) {
382
416
  if (this.#hasTargetFor(level)) {
383
417
  const target = level === "assertive" ? this.assertiveTarget : this.politeTarget;
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/auto_submit_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. */
@@ -32,11 +43,19 @@ var BeforeCacheReset = class _BeforeCacheReset {
32
43
  };
33
44
 
34
45
  // src/utils/composition_tracker.ts
46
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
47
+ "insertCompositionText",
48
+ "insertFromComposition",
49
+ "deleteCompositionText",
50
+ "deleteByComposition"
51
+ ]);
35
52
  var CompositionTracker = class {
36
53
  #observedTargets = /* @__PURE__ */ new Set();
37
54
  #activeTargets = /* @__PURE__ */ new Set();
38
55
  #onStart;
39
56
  #onEnd;
57
+ /** The field whose confirming `input` is still owed, while the window is open. */
58
+ #confirmedTarget = null;
40
59
  constructor(options = {}) {
41
60
  this.#onStart = options.onStart;
42
61
  this.#onEnd = options.onEnd;
@@ -46,6 +65,7 @@ var CompositionTracker = class {
46
65
  if (this.#observedTargets.has(target)) return;
47
66
  target.addEventListener("compositionstart", this.#handleStart);
48
67
  target.addEventListener("compositionend", this.#handleEnd);
68
+ target.addEventListener("keydown", this.#handleKeydown);
49
69
  this.#observedTargets.add(target);
50
70
  }
51
71
  /** Stops tracking one target and clears any active composition it owned. */
@@ -53,29 +73,76 @@ var CompositionTracker = class {
53
73
  if (!this.#observedTargets.delete(target)) return;
54
74
  target.removeEventListener("compositionstart", this.#handleStart);
55
75
  target.removeEventListener("compositionend", this.#handleEnd);
76
+ target.removeEventListener("keydown", this.#handleKeydown);
56
77
  this.#activeTargets.delete(target);
78
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
57
79
  }
58
80
  /** Releases every listener and clears state so reconnect starts cleanly. */
59
81
  disconnect() {
60
82
  for (const target of this.#observedTargets) {
61
83
  target.removeEventListener("compositionstart", this.#handleStart);
62
84
  target.removeEventListener("compositionend", this.#handleEnd);
85
+ target.removeEventListener("keydown", this.#handleKeydown);
63
86
  }
64
87
  this.#observedTargets.clear();
65
88
  this.#activeTargets.clear();
89
+ this.#confirmedTarget = null;
66
90
  }
67
91
  /** True when lifecycle tracking or the current event reports composition. */
68
92
  isComposing(event) {
69
93
  return this.#activeTargets.size > 0 || event?.isComposing === true;
70
94
  }
95
+ /**
96
+ * Whether `event` is the `input` echoing the composition just confirmed.
97
+ *
98
+ * Asking closes the window either way, so one confirmation is folded at most
99
+ * once and a consumer asks once per `input`.
100
+ */
101
+ consumesConfirmedInput(event) {
102
+ const confirmed = this.#confirmedTarget;
103
+ this.#confirmedTarget = null;
104
+ if (confirmed === null || confirmed !== event.target) return false;
105
+ const inputType = event.inputType;
106
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
107
+ }
71
108
  #handleStart = (event) => {
109
+ this.#confirmedTarget = null;
72
110
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
73
111
  this.#onStart?.(event);
74
112
  };
75
113
  #handleEnd = (event) => {
76
114
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
115
+ this.#confirmedTarget = event.target;
77
116
  this.#onEnd?.(event);
78
117
  };
118
+ /** A key on an observed field opens an edit of its own, so no echo is owed. */
119
+ #handleKeydown = () => {
120
+ this.#confirmedTarget = null;
121
+ };
122
+ };
123
+
124
+ // src/utils/listener_set.ts
125
+ var ListenerSet = class {
126
+ /** The generation every `add` joins until the next `dispose()`. */
127
+ #abort = new AbortController();
128
+ /**
129
+ * Attaches `handler` to the open generation, exactly as the caller spelled it.
130
+ *
131
+ * The set supplies the signal, so `options` carries everything else the DOM
132
+ * accepts — `capture` included, which has to match at release time and no
133
+ * longer has a second place to drift from.
134
+ */
135
+ add(target, type, handler, options) {
136
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
137
+ }
138
+ /**
139
+ * Releases every listener of the open generation, synchronously, and opens the
140
+ * next one. Idempotent, and safe before anything has been added.
141
+ */
142
+ dispose() {
143
+ this.#abort.abort();
144
+ this.#abort = new AbortController();
145
+ }
79
146
  };
80
147
 
81
148
  // src/utils/safe_timeout.ts
@@ -146,18 +213,23 @@ var AutoSubmitController = class extends Controller {
146
213
  #timers = new SafeTimeout();
147
214
  /** Id of the pending debounce timer, so a new keystroke can reset it. */
148
215
  #pendingId = 0;
216
+ /** Listeners that live with the bound form rather than with the connection. */
217
+ #formListeners = new ListenerSet();
149
218
  /** The form the listeners are attached to; target callbacks rebind it. */
150
219
  #boundForm = null;
151
220
  /** Rewinds the transient state hooks just before Turbo snapshots the page. */
152
221
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
153
- /** Clears `aria-busy` and emits completion once Turbo finishes the submit. */
222
+ /**
223
+ * Clears `aria-busy` and emits completion once Turbo finishes the submit.
224
+ *
225
+ * @stimeoRuntimeOnly `message` and `announce` shape the event and the announcement of one
226
+ * finished submit; the busy flag it clears does not depend on them.
227
+ */
154
228
  #onSubmitEnd = () => {
155
229
  this.#boundForm?.removeAttribute("aria-busy");
156
230
  const message = this.messageValue;
157
231
  this.dispatch("done", { detail: { message: message || void 0 } });
158
- if (this.announceValue && message) {
159
- window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
160
- }
232
+ if (this.announceValue) announce(message);
161
233
  };
162
234
  /** Owns delegated IME lifecycle state and submits confirmed input text. */
163
235
  #composition = new CompositionTracker({
@@ -194,7 +266,11 @@ var AutoSubmitController = class extends Controller {
194
266
  if (event.type === "input" && this.#composition.isComposing(event)) return;
195
267
  this.#schedule(event.target ?? null);
196
268
  }
197
- /** Schedules (and coalesces) the debounced submit for the given trigger. */
269
+ /**
270
+ * Schedules (and coalesces) the debounced submit for the given trigger.
271
+ *
272
+ * @stimeoRuntimeOnly `debounce` is the delay of the one submit timer this call arms.
273
+ */
198
274
  #schedule(trigger) {
199
275
  const form = this.#boundForm;
200
276
  if (!form) return;
@@ -228,12 +304,12 @@ var AutoSubmitController = class extends Controller {
228
304
  if (previous) {
229
305
  this.#cancelPending();
230
306
  previous.removeAttribute("data-auto-submit-pending");
231
- previous.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
307
+ this.#formListeners.dispose();
232
308
  this.#composition.unobserve(previous);
233
309
  }
234
310
  this.#boundForm = form;
235
311
  if (!form) return;
236
- form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
312
+ this.#formListeners.add(form, "turbo:submit-end", this.#onSubmitEnd);
237
313
  this.#composition.observe(form);
238
314
  }
239
315
  /** Resolves the form: the explicit `form` target, else a `<form>` root, else null. */
@@ -224,7 +224,7 @@ var AvatarController = class extends Controller {
224
224
  }
225
225
  /** Applies Value precedence and returns the effective raw `src` attribute. */
226
226
  #applySource(image) {
227
- if (!this.element.hasAttribute("data-stimeo--avatar-src-value")) {
227
+ if (!this.element.hasAttribute(`data-${this.identifier}-src-value`)) {
228
228
  this.#src.return(image);
229
229
  return image.getAttribute("src");
230
230
  }
@@ -63,10 +63,15 @@ var LayoutObserver = class {
63
63
  #resizeObserverFactory;
64
64
  #resizeObserver = null;
65
65
  #observingViewport = false;
66
+ #loadContainer = null;
66
67
  /** Stable bound handler so add/removeEventListener target the same reference. */
67
68
  #handleViewportResize = () => {
68
69
  this.#callback();
69
70
  };
71
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
72
+ #handleDescendantLoad = () => {
73
+ this.#callback();
74
+ };
70
75
  constructor(callback, options = {}) {
71
76
  this.#callback = callback;
72
77
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -102,14 +107,35 @@ var LayoutObserver = class {
102
107
  window.removeEventListener("resize", this.#handleViewportResize);
103
108
  }
104
109
  /**
105
- * Releases every observation: disconnects the {@link ResizeObserver} and
106
- * removes the viewport listener. Safe to call multiple times. Call this from a
107
- * controller's `disconnect()`.
110
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
111
+ * frame settling changes the box it sits in, and it measures as zero high until
112
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
113
+ * on the container itself and nothing the caller spells.
114
+ *
115
+ * **One container at a time.** A further call moves the observation, so a widget
116
+ * whose content element is swapped at runtime releases the element it let go by
117
+ * naming the new one — there is no second place for the release to drift from.
118
+ */
119
+ observeDescendantLoads(container) {
120
+ this.unobserveDescendantLoads();
121
+ this.#loadContainer = container;
122
+ container.addEventListener("load", this.#handleDescendantLoad, true);
123
+ }
124
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
125
+ unobserveDescendantLoads() {
126
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
127
+ this.#loadContainer = null;
128
+ }
129
+ /**
130
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
131
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
132
+ * this from a controller's `disconnect()`.
108
133
  */
109
134
  disconnect() {
110
135
  this.#resizeObserver?.disconnect();
111
136
  this.#resizeObserver = null;
112
137
  this.unobserveViewport();
138
+ this.unobserveDescendantLoads();
113
139
  }
114
140
  };
115
141
 
@@ -161,21 +187,24 @@ var BreadcrumbController = class extends Controller {
161
187
  this.#resync();
162
188
  }
163
189
  /**
164
- * Re-measures when the disclosure set gains or loses a member. The set is a
165
- * precondition for collapsing at all, so it needs the same watching the list
166
- * and items get: without these callbacks a swap that breaks or completes the
167
- * set would only take effect at the next resize or list mutation, which may
168
- * never come.
190
+ * Re-measures when an `ellipsis` target arrives. The `ellipsis` and `trigger`
191
+ * targets form the disclosure set, a precondition for collapsing at all, so they
192
+ * need the same watching the list and items get: without their callbacks a swap
193
+ * that breaks or completes the set would only take effect at the next resize or
194
+ * list mutation, which may never come.
169
195
  */
170
196
  ellipsisTargetConnected() {
171
197
  this.#resync();
172
198
  }
199
+ /** Re-measures when an `ellipsis` target leaves the disclosure set. */
173
200
  ellipsisTargetDisconnected() {
174
201
  this.#resync();
175
202
  }
203
+ /** Re-measures when a `trigger` target joins the disclosure set. */
176
204
  triggerTargetConnected() {
177
205
  this.#resync();
178
206
  }
207
+ /** Re-measures when a `trigger` target leaves the disclosure set. */
179
208
  triggerTargetDisconnected() {
180
209
  this.#resync();
181
210
  }
@@ -184,7 +213,7 @@ var BreadcrumbController = class extends Controller {
184
213
  *
185
214
  * The marker is the source of truth for what may be collapsed, so an element
186
215
  * that loses it while the controller is live is an always-visible item again —
187
- * and `#render` only walks the *current* targets, so nothing else would ever
216
+ * and rendering only walks the *current* targets, so nothing else would ever
188
217
  * clear the `hidden` this controller put there.
189
218
  *
190
219
  * The `#connected` guard is load-bearing, not defensive. Stimulus fires this
@@ -289,8 +318,6 @@ var BreadcrumbController = class extends Controller {
289
318
  }
290
319
  /**
291
320
  * Applies the collapsed/expanded state to the items, ellipsis, and trigger.
292
- *
293
- * @stimeoRenderRoot
294
321
  */
295
322
  #render() {
296
323
  const collapsed = this.#overflowing && !this.#expanded;
@@ -108,12 +108,8 @@ var BulkSelectController = class extends Controller {
108
108
  allTargetDisconnected() {
109
109
  this.#reconcile.schedule();
110
110
  }
111
- /** Repaints the count for a total that changed at runtime, rejecting non-finite ones. */
111
+ /** Repaints the count for a total that changed at runtime. */
112
112
  totalCountValueChanged() {
113
- if (!Number.isFinite(this.totalCountValue)) {
114
- this.totalCountValue = 0;
115
- return;
116
- }
117
113
  this.#reconcile.schedule();
118
114
  }
119
115
  /** Repaints so wording changed at runtime is used by the next announcement. */
@@ -175,6 +171,11 @@ var BulkSelectController = class extends Controller {
175
171
  #announce(detail) {
176
172
  announce(fillTemplate(this.announceTextValue, { count: detail.count }));
177
173
  }
174
+ /** The declared `totalCount`, or the default `0` in place of a non-finite one. */
175
+ get #totalCount() {
176
+ const declared = this.totalCountValue;
177
+ return Number.isFinite(declared) ? declared : 0;
178
+ }
178
179
  /**
179
180
  * Recomputes the count, the select-all checked/indeterminate state, and the bar
180
181
  * visibility from the current DOM. Returns the figures when the emitted count or
@@ -191,7 +192,7 @@ var BulkSelectController = class extends Controller {
191
192
  this.allTarget.checked = total > 0 && checked === total;
192
193
  this.allTarget.indeterminate = checked > 0 && checked < total;
193
194
  }
194
- const count = allPages ? this.totalCountValue : checked;
195
+ const count = allPages ? this.#totalCount : checked;
195
196
  const show = allPages || checked > 0;
196
197
  if (this.hasBarTarget) {
197
198
  if (!show && this.hasAllTarget && this.barTarget.contains(document.activeElement)) {