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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +145 -0
- data/dist/cable/index.js +249 -45
- data/dist/controllers/count_up_controller.js +74 -35
- data/dist/controllers/intersection_controller.js +55 -8
- data/dist/controllers/lazy_frame_controller.js +81 -19
- data/dist/controllers/pointer_drag_controller.js +68 -18
- data/dist/controllers/portal_controller.js +32 -11
- data/dist/controllers/preview_guard_controller.js +180 -20
- data/dist/controllers/reading_progress_controller.js +177 -6
- data/dist/controllers/roving_controller.js +127 -14
- data/dist/controllers/scrollspy_controller.js +13 -2
- data/dist/controllers/smart_sticky_header_controller.js +52 -10
- data/dist/controllers/sortable_controller.js +241 -67
- data/dist/controllers/stick_to_bottom_controller.js +149 -28
- data/dist/controllers/sticky_observer_controller.js +13 -2
- data/dist/index.js +934 -285
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -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:
|
|
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
|
-
/**
|
|
27
|
-
#
|
|
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
|
-
|
|
36
|
-
|
|
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.#
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
106
|
-
*
|
|
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
|
-
#
|
|
109
|
-
|
|
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
|
-
/**
|
|
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.#
|
|
121
|
-
this.#layout
|
|
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
|
-
/**
|
|
138
|
-
|
|
139
|
-
|
|
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 :
|
|
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) => {
|