stimeo-ui 0.10.0 → 0.12.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.
@@ -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
@@ -9,6 +9,14 @@ function isBeforeRootStart(entry) {
9
9
  const rootTop = entry.rootBounds?.top ?? 0;
10
10
  return rect.bottom <= rootTop;
11
11
  }
12
+ function queryRoot(selector) {
13
+ if (!selector) return null;
14
+ try {
15
+ return document.querySelector(selector);
16
+ } catch {
17
+ }
18
+ return null;
19
+ }
12
20
  var IntersectionWatcher = class {
13
21
  #onEntries;
14
22
  #observer = null;
@@ -30,7 +38,10 @@ var IntersectionWatcher = class {
30
38
  * the watcher inert — without `IntersectionObserver` support (very old
31
39
  * browsers; the caller's no-JS fallback stays in charge) or with no targets.
32
40
  * If initial construction with the configured options fails, the watcher
33
- * warns and retries once with the same root and platform defaults.
41
+ * warns and retries once with the same root and platform defaults. A
42
+ * `rootSelector` that does not parse resolves to the viewport (see
43
+ * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the
44
+ * call.
34
45
  *
35
46
  * @throws The fallback constructor error if both construction attempts fail,
36
47
  * or whatever the platform throws from `observe()`. The exception is passed
@@ -43,7 +54,7 @@ var IntersectionWatcher = class {
43
54
  if (typeof IntersectionObserver === "undefined") return false;
44
55
  const list = Array.isArray(targets) ? targets : [targets];
45
56
  if (list.length === 0) return false;
46
- const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
57
+ const root = "root" in options ? options.root ?? null : queryRoot(options.rootSelector);
47
58
  let observer = null;
48
59
  try {
49
60
  const onEntries = (entries) => {