stimeo-ui 0.9.0 → 0.11.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -0
  3. data/dist/cable/index.js +226 -43
  4. data/dist/controllers/alert_dialog_controller.js +17 -7
  5. data/dist/controllers/announcer_controller.js +1 -1
  6. data/dist/controllers/bulk_select_controller.js +2 -2
  7. data/dist/controllers/collapsible_controller.js +1 -1
  8. data/dist/controllers/command_palette_controller.js +15 -5
  9. data/dist/controllers/confirm_controller.js +15 -5
  10. data/dist/controllers/countdown_controller.js +1 -1
  11. data/dist/controllers/data_grid_controller.js +1 -1
  12. data/dist/controllers/dialog_controller.js +15 -5
  13. data/dist/controllers/direct_upload_controller.js +1 -5
  14. data/dist/controllers/drawer_controller.js +17 -7
  15. data/dist/controllers/focus_controller.js +45 -7
  16. data/dist/controllers/form_field_controller.js +1 -1
  17. data/dist/controllers/menubar_controller.js +2 -2
  18. data/dist/controllers/multi_select_controller.js +1 -1
  19. data/dist/controllers/overflow_menu_controller.js +2 -2
  20. data/dist/controllers/password_reveal_controller.js +102 -3
  21. data/dist/controllers/password_strength_controller.js +287 -42
  22. data/dist/controllers/pointer_drag_controller.js +64 -18
  23. data/dist/controllers/portal_controller.js +32 -11
  24. data/dist/controllers/preview_guard_controller.js +180 -20
  25. data/dist/controllers/resizable_controller.js +1 -1
  26. data/dist/controllers/roving_controller.js +127 -14
  27. data/dist/controllers/scroll_restore_controller.js +127 -27
  28. data/dist/controllers/scroll_visibility_controller.js +167 -14
  29. data/dist/controllers/sidebar_controller.js +15 -5
  30. data/dist/controllers/step_indicator_controller.js +0 -5
  31. data/dist/controllers/stick_to_bottom_controller.js +149 -28
  32. data/dist/controllers/theme_controller.js +151 -40
  33. data/dist/controllers/toast_controller.js +2 -2
  34. data/dist/controllers/transition_controller.js +79 -15
  35. data/dist/index.js +1188 -318
  36. data/dist/positioning/index.js +84 -25
  37. data/lib/stimeo/ui/version.rb +1 -1
  38. metadata +2 -2
@@ -2,6 +2,61 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/scroll_visibility_controller.ts
4
4
 
5
+ // src/utils/blur_deferral.ts
6
+ var BlurDeferral = class {
7
+ /** Elements currently holding an update back, mapped to their `blur` listener. */
8
+ #pending = /* @__PURE__ */ new Map();
9
+ /** Called after a pending element blurs and has been detached. */
10
+ #onRelease;
11
+ /** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
12
+ constructor(onRelease) {
13
+ this.#onRelease = onRelease;
14
+ }
15
+ /** Number of elements currently holding an update back. */
16
+ get size() {
17
+ return this.#pending.size;
18
+ }
19
+ /** Snapshot of the pending elements, safe to iterate while releasing them. */
20
+ get elements() {
21
+ return [...this.#pending.keys()];
22
+ }
23
+ /** Whether `element` is currently holding an update back. */
24
+ has(element) {
25
+ return this.#pending.has(element);
26
+ }
27
+ /** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
28
+ defer(element) {
29
+ if (this.#pending.has(element)) return;
30
+ const onBlur = () => {
31
+ this.#detach(element);
32
+ this.#onRelease(element);
33
+ };
34
+ this.#pending.set(element, onBlur);
35
+ element.addEventListener("blur", onBlur);
36
+ }
37
+ /** Defers `element` as the only pending entry, cancelling any others. */
38
+ deferOnly(element) {
39
+ for (const pending of this.elements) {
40
+ if (pending !== element) this.#detach(pending);
41
+ }
42
+ this.defer(element);
43
+ }
44
+ /** Cancels `element`'s deferral without completing it; no-ops when not pending. */
45
+ release(element) {
46
+ this.#detach(element);
47
+ }
48
+ /** Cancels every deferral without completing any of them. */
49
+ releaseAll() {
50
+ for (const element of this.elements) this.#detach(element);
51
+ }
52
+ /** Removes the `blur` listener for `element` and forgets it. */
53
+ #detach(element) {
54
+ const onBlur = this.#pending.get(element);
55
+ if (onBlur) element.removeEventListener("blur", onBlur);
56
+ this.#pending.delete(element);
57
+ }
58
+ };
59
+
5
60
  // src/utils/reduced_motion.ts
6
61
  function prefersReducedMotion() {
7
62
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -68,10 +123,11 @@ var TabindexLoan = class {
68
123
  };
69
124
 
70
125
  // src/controllers/scroll_visibility_controller.ts
126
+ var DEFAULT_OFFSET = 400;
71
127
  var ScrollVisibilityController = class extends Controller {
72
128
  static targets = ["element"];
73
129
  static values = {
74
- offset: { type: Number, default: 400 },
130
+ offset: { type: Number, default: DEFAULT_OFFSET },
75
131
  mode: { type: String, default: "offset" },
76
132
  focusSelector: { type: String, default: "" },
77
133
  root: { type: String, default: "" }
@@ -89,8 +145,32 @@ var ScrollVisibilityController = class extends Controller {
89
145
  * the window. Captured on connect so teardown detaches from the same source.
90
146
  */
91
147
  #scrollSource = window;
148
+ /**
149
+ * Gates the declaration callbacks to the connected window.
150
+ *
151
+ * Stimulus delivers a Value callback ahead of `connect()` and again for every
152
+ * runtime change; without the gate, merely connecting would evaluate — and
153
+ * announce — before `connect()` runs its own first reflection.
154
+ */
155
+ #connected = false;
156
+ /** Validated threshold; a non-finite declaration reads as the default. */
157
+ #offset = DEFAULT_OFFSET;
158
+ /** Validated `root` selector; an unparsable declaration reads as absent. */
159
+ #rootSelector = "";
160
+ /** Validated `focusSelector`; an unparsable declaration reads as absent. */
161
+ #focusSelector = "";
92
162
  /** Focus targets this instance lent a `tabindex` to. */
93
163
  #tabindex = new TabindexLoan();
164
+ /**
165
+ * The hide held back while the control itself owns focus.
166
+ *
167
+ * Completing the deferral re-runs the ordinary evaluation rather than applying
168
+ * the stale decision: by the time focus leaves, the scroll position may have
169
+ * moved back past the threshold.
170
+ */
171
+ #pendingHide = new BlurDeferral(() => {
172
+ if (this.#connected) this.#evaluate();
173
+ });
94
174
  #onScroll = () => {
95
175
  if (this.#rafId !== null) return;
96
176
  this.#rafId = requestAnimationFrame(() => {
@@ -102,61 +182,134 @@ var ScrollVisibilityController = class extends Controller {
102
182
  this.#scrollSource = this.#resolveScrollSource();
103
183
  this.#lastScrollY = this.#scrollY();
104
184
  this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
105
- this.#evaluate();
185
+ this.#evaluate(false);
186
+ this.#connected = true;
106
187
  }
107
188
  disconnect() {
189
+ this.#connected = false;
108
190
  this.#scrollSource.removeEventListener("scroll", this.#onScroll);
109
191
  if (this.#rafId !== null) {
110
192
  cancelAnimationFrame(this.#rafId);
111
193
  this.#rafId = null;
112
194
  }
195
+ this.#pendingHide.releaseAll();
113
196
  this.#tabindex.returnAll();
114
197
  this.#visible = null;
115
198
  }
199
+ /** Writes the current visibility onto a control that arrives after connect. */
200
+ elementTargetConnected(element) {
201
+ if (this.#visible !== null) element.hidden = !this.#visible;
202
+ }
203
+ /** Drops a held-back hide together with the control it was waiting on. */
204
+ elementTargetDisconnected() {
205
+ this.#pendingHide.releaseAll();
206
+ }
207
+ /**
208
+ * Validates `offset` once, then re-renders.
209
+ *
210
+ * Re-renders when application code (or a Turbo morph) changes `offset` at
211
+ * runtime. A declaration that is not a finite number reads as the default, so
212
+ * the comparison path never sees `NaN` — which would answer `false` to every
213
+ * comparison and strand the element (in `direction` mode, even the guarantee
214
+ * that the very top always reveals).
215
+ */
216
+ offsetValueChanged() {
217
+ this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
218
+ if (this.#connected) this.#evaluate();
219
+ }
220
+ /** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */
221
+ modeValueChanged() {
222
+ if (this.#connected) this.#evaluate();
223
+ }
224
+ /** Validates `root` once so connect never parses a selector that throws. */
225
+ rootValueChanged() {
226
+ this.#rootSelector = this.#validSelector(this.rootValue);
227
+ }
228
+ /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
229
+ focusSelectorValueChanged() {
230
+ this.#focusSelector = this.#validSelector(this.focusSelectorValue);
231
+ }
116
232
  /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
117
233
  toTop() {
118
234
  const behavior = prefersReducedMotion() ? "instant" : "smooth";
119
235
  this.#scrollSource.scrollTo({ top: 0, behavior });
120
- if (this.focusSelectorValue) {
121
- const target = document.querySelector(this.focusSelectorValue);
236
+ if (this.#focusSelector) {
237
+ const target = document.querySelector(this.#focusSelector);
122
238
  if (target) {
123
239
  this.#tabindex.lend(target);
124
- target.focus();
240
+ target.focus({ preventScroll: true });
125
241
  }
126
242
  }
127
243
  }
128
- /** Decides the next visibility from the current scroll state and applies it. */
129
- #evaluate() {
244
+ /**
245
+ * Decides the next visibility from the current scroll state and applies it.
246
+ *
247
+ * @param notify - whether a transition announces itself. The reflection
248
+ * `connect()` performs is the current state, not a change.
249
+ *
250
+ * @stimeoRenderRoot
251
+ */
252
+ #evaluate(notify = true) {
130
253
  const y = this.#scrollY();
131
254
  let nextVisible;
132
255
  if (this.modeValue === "direction") {
133
- if (y <= this.offsetValue) {
256
+ if (y <= this.#offset) {
134
257
  nextVisible = true;
258
+ } else if (y === this.#lastScrollY) {
259
+ return;
135
260
  } else {
136
261
  nextVisible = y < this.#lastScrollY;
137
262
  }
138
263
  } else {
139
- nextVisible = y > this.offsetValue;
264
+ nextVisible = y > this.#offset;
140
265
  }
141
266
  this.#lastScrollY = y;
142
- this.#setVisible(nextVisible);
267
+ this.#setVisible(nextVisible, notify);
143
268
  }
144
269
  /** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */
145
- #setVisible(next) {
270
+ #setVisible(next, notify) {
146
271
  if (next === this.#visible) return;
272
+ const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;
273
+ if (focused) {
274
+ this.#pendingHide.deferOnly(focused);
275
+ return;
276
+ }
147
277
  this.#visible = next;
148
278
  if (this.hasElementTarget) this.elementTarget.hidden = !next;
149
279
  this.element.setAttribute("data-state", next ? "visible" : "hidden");
150
- this.dispatch("change", { detail: { visible: next } });
280
+ if (notify) this.dispatch("change", { detail: { visible: next } });
151
281
  }
152
282
  /** Resolves the scroll source from `root` (falling back to the window). */
153
283
  #resolveScrollSource() {
154
- if (this.rootValue) {
155
- const root = document.querySelector(this.rootValue);
284
+ if (this.#rootSelector) {
285
+ const root = document.querySelector(this.#rootSelector);
156
286
  if (root) return root;
157
287
  }
158
288
  return window;
159
289
  }
290
+ /**
291
+ * The focus owner inside the target, or `null` when focus is elsewhere.
292
+ *
293
+ * `blur` does not bubble, so the deferral has to ride the focused element
294
+ * itself: waiting on a container that never receives the event would hold the
295
+ * hide forever.
296
+ */
297
+ #focusedWithin() {
298
+ const focused = document.activeElement;
299
+ if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
300
+ return null;
301
+ }
302
+ /** Returns `declared` when it parses as a selector, and `""` when it does not. */
303
+ #validSelector(declared) {
304
+ if (declared.length > 0) {
305
+ try {
306
+ this.element.matches(declared);
307
+ return declared;
308
+ } catch {
309
+ }
310
+ }
311
+ return "";
312
+ }
160
313
  #scrollY() {
161
314
  if (this.#scrollSource === window) {
162
315
  return window.scrollY ?? window.pageYOffset ?? 0;
@@ -315,15 +315,25 @@ var FocusTrap = class {
315
315
  * content cannot be focused or reached by assistive technology, honoring the
316
316
  * `aria-modal="true"` contract. An element that was *already* `inert` is left
317
317
  * untracked so `#releaseBackground` does not wrongly clear it.
318
+ *
319
+ * The walk climbs from the container to `body` and inerts each ancestor's other
320
+ * children. Scanning only `body`'s children would skip the branch the container
321
+ * sits in — everything beside it inside that branch is background too, and a
322
+ * nested container is the ordinary case.
318
323
  */
319
324
  #isolateBackground() {
320
325
  const container = this.#getContainer();
321
326
  this.#inertedSiblings = [];
322
- for (const sibling of Array.from(document.body.children)) {
323
- if (!(sibling instanceof HTMLElement)) continue;
324
- if (sibling.contains(container) || sibling.inert) continue;
325
- sibling.inert = true;
326
- this.#inertedSiblings.push(sibling);
327
+ for (let node = container; node !== document.body; ) {
328
+ const parent = node.parentElement;
329
+ if (!parent) break;
330
+ for (const sibling of Array.from(parent.children)) {
331
+ if (!(sibling instanceof HTMLElement)) continue;
332
+ if (sibling === node || sibling.inert) continue;
333
+ sibling.inert = true;
334
+ this.#inertedSiblings.push(sibling);
335
+ }
336
+ node = parent;
327
337
  }
328
338
  }
329
339
  /** Reverts the `inert` flags applied by `#isolateBackground`. */
@@ -43,11 +43,6 @@ var StepIndicatorController = class extends Controller {
43
43
  };
44
44
  static actions = ["setCurrent"];
45
45
  static events = ["change"];
46
- /**
47
- * Whether the target callbacks may render. Stimulus reports the authored steps
48
- * as connected before `connect()` and the remaining ones as disconnected after
49
- * `disconnect()`, so this keeps a connect at one render pass, not one per step.
50
- */
51
46
  /**
52
47
  * Collapses a batch of step callbacks — and a morph that swaps `current` with
53
48
  * them — into one repaint. Replacing a list of N steps delivers N callbacks, and
@@ -2,12 +2,69 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/stick_to_bottom_controller.ts
4
4
 
5
+ // src/utils/layout_observer.ts
6
+ var LayoutObserver = class {
7
+ #callback;
8
+ #resizeObserverFactory;
9
+ #resizeObserver = null;
10
+ #observingViewport = false;
11
+ /** Stable bound handler so add/removeEventListener target the same reference. */
12
+ #handleViewportResize = () => {
13
+ this.#callback();
14
+ };
15
+ constructor(callback, options = {}) {
16
+ this.#callback = callback;
17
+ this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
18
+ }
19
+ /**
20
+ * Starts observing an element's size. Repeated calls observe additional
21
+ * elements through the same shared observer. No-ops when no
22
+ * `ResizeObserver` implementation is available.
23
+ */
24
+ observe(element) {
25
+ if (!this.#resizeObserverFactory) return;
26
+ if (!this.#resizeObserver) {
27
+ this.#resizeObserver = this.#resizeObserverFactory(() => {
28
+ this.#callback();
29
+ });
30
+ }
31
+ this.#resizeObserver.observe(element);
32
+ }
33
+ /** Stops observing a single element while leaving any others in place. */
34
+ unobserve(element) {
35
+ this.#resizeObserver?.unobserve(element);
36
+ }
37
+ /** Starts observing viewport resizes. Idempotent: the listener is added once. */
38
+ observeViewport() {
39
+ if (this.#observingViewport) return;
40
+ this.#observingViewport = true;
41
+ window.addEventListener("resize", this.#handleViewportResize);
42
+ }
43
+ /** Stops observing viewport resizes without affecting element observation. */
44
+ unobserveViewport() {
45
+ if (!this.#observingViewport) return;
46
+ this.#observingViewport = false;
47
+ window.removeEventListener("resize", this.#handleViewportResize);
48
+ }
49
+ /**
50
+ * Releases every observation: disconnects the {@link ResizeObserver} and
51
+ * removes the viewport listener. Safe to call multiple times. Call this from a
52
+ * controller's `disconnect()`.
53
+ */
54
+ disconnect() {
55
+ this.#resizeObserver?.disconnect();
56
+ this.#resizeObserver = null;
57
+ this.unobserveViewport();
58
+ }
59
+ };
60
+
5
61
  // src/utils/reduced_motion.ts
6
62
  function prefersReducedMotion() {
7
63
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8
64
  }
9
65
 
10
66
  // src/controllers/stick_to_bottom_controller.ts
67
+ var DEFAULT_THRESHOLD = 80;
11
68
  var countElements = (nodes) => {
12
69
  let n = 0;
13
70
  for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
@@ -16,34 +73,53 @@ var countElements = (nodes) => {
16
73
  var StickToBottomController = class extends Controller {
17
74
  static targets = ["content"];
18
75
  static values = {
19
- threshold: { type: Number, default: 80 },
76
+ threshold: { type: Number, default: DEFAULT_THRESHOLD },
20
77
  behavior: { type: String, default: "auto" },
21
78
  pinOnConnect: { type: Boolean, default: false }
22
79
  };
23
80
  static actions = ["scrollToBottom"];
24
81
  static events = ["pin", "new"];
25
82
  #observer = null;
26
- /** Watches for the box a deferred `pinOnConnect` jump is still waiting on. */
27
- #layout = null;
83
+ /** The element the append observer currently holds, so a swap can be detected. */
84
+ #watched = null;
85
+ /** Watches for the box a container connected without one is still waiting on. */
86
+ #layout = new LayoutObserver(() => this.#onLaidOut());
87
+ #awaitingLayout = false;
88
+ #connected = false;
28
89
  #pinned = false;
29
90
  #onScroll = () => this.#updatePinned();
30
91
  connect() {
92
+ this.#connected = true;
31
93
  if (this.pinOnConnectValue && this.#measurable()) this.#scrollToBottom("instant");
94
+ this.element.removeAttribute("data-has-new");
32
95
  this.#pinned = this.#isPinned();
33
96
  this.#reflectPinned();
34
97
  this.element.addEventListener("scroll", this.#onScroll, { passive: true });
35
- if (typeof MutationObserver !== "undefined") {
36
- this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
37
- this.#observer.observe(this.#watched(), { childList: true });
38
- }
39
- if (this.pinOnConnectValue && !this.#measurable()) this.#pinWhenLaidOut();
98
+ this.#syncWatched();
99
+ if (!this.#measurable()) this.#waitForLayout();
40
100
  }
41
101
  disconnect() {
102
+ this.#connected = false;
42
103
  this.element.removeEventListener("scroll", this.#onScroll);
43
- this.#observer?.disconnect();
44
- this.#observer = null;
104
+ this.#stopWatching();
45
105
  this.#stopWaitingForLayout();
46
106
  }
107
+ /** Moves the append watch onto a `content` target that arrived at runtime. */
108
+ contentTargetConnected() {
109
+ this.#syncWatched();
110
+ }
111
+ /** Moves the append watch off a `content` target that left, back onto the container. */
112
+ contentTargetDisconnected() {
113
+ this.#syncWatched();
114
+ }
115
+ /**
116
+ * Re-derives pinned when the distance that counts as the bottom is changed at runtime
117
+ * (a morph that swaps the attribute on a retained element).
118
+ */
119
+ thresholdValueChanged() {
120
+ if (!this.#connected) return;
121
+ this.#updatePinned();
122
+ }
47
123
  /**
48
124
  * Jumps to the bottom and re-pins (wired to a "new messages" button).
49
125
  *
@@ -72,7 +148,11 @@ var StickToBottomController = class extends Controller {
72
148
  this.dispatch("new", { detail: { count: added } });
73
149
  }
74
150
  }
75
- /** Recomputes pinned from the scroll position and reflects it on a transition. */
151
+ /**
152
+ * Recomputes pinned from the scroll position and reflects it on a transition.
153
+ *
154
+ * @stimeoRenderRoot
155
+ */
76
156
  #updatePinned() {
77
157
  const pinned = this.#isPinned();
78
158
  if (pinned === this.#pinned) return;
@@ -89,9 +169,25 @@ var StickToBottomController = class extends Controller {
89
169
  this.element.removeAttribute("data-pinned");
90
170
  }
91
171
  }
172
+ /** Whether the container currently sits within `threshold` of its bottom. */
92
173
  #isPinned() {
174
+ if (!this.#measurable()) return false;
93
175
  const el = this.element;
94
- return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;
176
+ return el.scrollHeight - el.clientHeight - el.scrollTop <= this.#threshold;
177
+ }
178
+ /**
179
+ * The distance from the bottom that counts as pinned: a finite, non-negative number of
180
+ * pixels. Anything else names no distance the container can be at, and settles the
181
+ * comparison the same way at every scroll position, so it falls back to the default.
182
+ * `Number` reads `"abc"` as `NaN` and every comparison against it is false; a negative
183
+ * distance sits below the closest the container ever gets; `Infinity` is never
184
+ * exceeded. The first two stop following and flag every append as new, and the last
185
+ * never stops following — it takes the reading position the flag exists to protect.
186
+ * Zero is a real declaration: it pins at the exact bottom only.
187
+ */
188
+ get #threshold() {
189
+ const declared = this.thresholdValue;
190
+ return Number.isFinite(declared) && declared >= 0 ? declared : DEFAULT_THRESHOLD;
95
191
  }
96
192
  /**
97
193
  * Whether the container has a box to scroll and to measure. One that is not rendered
@@ -102,23 +198,25 @@ var StickToBottomController = class extends Controller {
102
198
  return this.element.clientHeight > 0;
103
199
  }
104
200
  /**
105
- * Holds the `pinOnConnect` jump until the container is laid out, then runs it and
106
- * re-reads the state — otherwise the panel opens at the top still claiming the bottom.
201
+ * Holds the pinned decision until the container is laid out — otherwise the panel opens
202
+ * at the top while the state claims the bottom, and the appends that arrived meanwhile
203
+ * were followed into a box that could not move rather than flagged.
107
204
  */
108
- #pinWhenLaidOut() {
109
- if (typeof ResizeObserver === "undefined") return;
110
- this.#layout = new ResizeObserver(() => {
111
- if (!this.#measurable()) return;
112
- this.#stopWaitingForLayout();
113
- this.#scrollToBottom("instant");
114
- this.#updatePinned();
115
- });
205
+ #waitForLayout() {
206
+ this.#awaitingLayout = true;
116
207
  this.#layout.observe(this.element);
117
208
  }
118
- /** Releases the layout watch, whether or not the deferred jump ever ran. */
209
+ /** Runs the held decision once the container has the box it was waiting for. */
210
+ #onLaidOut() {
211
+ if (!this.#awaitingLayout || !this.#measurable()) return;
212
+ this.#stopWaitingForLayout();
213
+ if (this.pinOnConnectValue) this.#scrollToBottom("instant");
214
+ this.#updatePinned();
215
+ }
216
+ /** Releases the layout watch, whether or not the held decision ever ran. */
119
217
  #stopWaitingForLayout() {
120
- this.#layout?.disconnect();
121
- this.#layout = null;
218
+ this.#awaitingLayout = false;
219
+ this.#layout.disconnect();
122
220
  }
123
221
  /**
124
222
  * Scrolls to the bottom, clamped by the engine to the maximum scroll offset — which is
@@ -134,9 +232,32 @@ var StickToBottomController = class extends Controller {
134
232
  this.element.scrollTop = top;
135
233
  }
136
234
  }
137
- /** The append-watched element: the `content` target, or the container itself. */
138
- #watched() {
139
- return this.hasContentTarget ? this.contentTarget : this.element;
235
+ /**
236
+ * Points the append watch at the current `content` target, or at the container when
237
+ * there is none. Re-resolved whenever that target changes, so a swap does not leave the
238
+ * observer holding a detached node whose appends nobody sees.
239
+ *
240
+ * Stimulus runs the target callbacks outside the connected window too — before
241
+ * `connect()` for a target already in the DOM, and after `disconnect()` while the
242
+ * element is torn down — where this would arm an observer nothing releases. Re-syncing
243
+ * to the target already held is left alone, so an arrival still in flight is not
244
+ * dropped with the observer that was about to deliver it.
245
+ */
246
+ #syncWatched() {
247
+ if (!this.#connected) return;
248
+ const next = this.hasContentTarget ? this.contentTarget : this.element;
249
+ if (next === this.#watched) return;
250
+ this.#stopWatching();
251
+ this.#watched = next;
252
+ if (typeof MutationObserver === "undefined") return;
253
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
254
+ this.#observer.observe(next, { childList: true });
255
+ }
256
+ /** Releases the append watch and the element it held. */
257
+ #stopWatching() {
258
+ this.#observer?.disconnect();
259
+ this.#observer = null;
260
+ this.#watched = null;
140
261
  }
141
262
  /**
142
263
  * The behavior a follow-scroll runs with. `"auto"` is **not** a request to arrive at