stimeo-ui 0.13.0 → 0.15.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 +73 -5
- data/dist/cable/index.js +60 -14
- data/dist/controllers/announcer_controller.js +88 -61
- data/dist/controllers/auto_submit_controller.js +40 -5
- data/dist/controllers/breadcrumb_controller.js +29 -3
- data/dist/controllers/calendar_controller.js +51 -10
- data/dist/controllers/carousel_controller.js +8 -6
- data/dist/controllers/checkbox_controller.js +27 -2
- data/dist/controllers/conditional_fields_controller.js +36 -7
- data/dist/controllers/currency_input_controller.js +77 -25
- data/dist/controllers/date_range_picker_controller.js +43 -10
- data/dist/controllers/direct_upload_controller.js +21 -10
- data/dist/controllers/dirty_form_controller.js +32 -12
- data/dist/controllers/flash_controller.js +145 -43
- data/dist/controllers/form_field_controller.js +7 -1
- data/dist/controllers/frame_loading_controller.js +29 -6
- data/dist/controllers/highlight_controller.js +63 -23
- data/dist/controllers/input_mask_controller.js +33 -12
- data/dist/controllers/intersection_controller.js +10 -5
- data/dist/controllers/lazy_frame_controller.js +10 -5
- data/dist/controllers/local_time_controller.js +43 -11
- data/dist/controllers/masonry_controller.js +32 -14
- data/dist/controllers/menu_controller.js +10 -2
- data/dist/controllers/menubar_controller.js +10 -2
- data/dist/controllers/navigation_menu_controller.js +8 -2
- data/dist/controllers/optimistic_controller.js +28 -4
- data/dist/controllers/otp_controller.js +23 -10
- data/dist/controllers/overflow_indicator_controller.js +31 -8
- data/dist/controllers/overflow_menu_controller.js +39 -4
- data/dist/controllers/pointer_drag_controller.js +10 -3
- data/dist/controllers/portal_controller.js +21 -10
- data/dist/controllers/radio_group_controller.js +11 -3
- data/dist/controllers/read_more_controller.js +31 -5
- data/dist/controllers/reading_progress_controller.js +65 -19
- data/dist/controllers/relative_time_controller.js +40 -9
- data/dist/controllers/reset_before_cache_controller.js +23 -10
- data/dist/controllers/resizable_controller.js +7 -2
- data/dist/controllers/roving_controller.js +7 -3
- data/dist/controllers/scroll_area_controller.js +70 -26
- data/dist/controllers/scroll_restore_controller.js +37 -16
- data/dist/controllers/scroll_visibility_controller.js +71 -42
- data/dist/controllers/scrollspy_controller.js +76 -32
- data/dist/controllers/smart_sticky_header_controller.js +72 -37
- data/dist/controllers/stick_to_bottom_controller.js +29 -3
- data/dist/controllers/sticky_observer_controller.js +10 -5
- data/dist/controllers/submit_once_controller.js +29 -6
- data/dist/controllers/textarea_autosize_controller.js +29 -3
- data/dist/controllers/theme_controller.js +23 -13
- data/dist/controllers/toast_controller.js +136 -73
- data/dist/controllers/toggle_group_controller.js +11 -3
- data/dist/controllers/toolbar_controller.js +7 -3
- data/dist/index.js +629 -597
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -57,11 +57,73 @@ var BlurDeferral = class {
|
|
|
57
57
|
}
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
// src/utils/declared_value.ts
|
|
61
|
+
function parseDeclared(raw, parse, fallback) {
|
|
62
|
+
try {
|
|
63
|
+
return parse(raw);
|
|
64
|
+
} catch {
|
|
65
|
+
return fallback;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function validSelector(element, raw, fallback) {
|
|
69
|
+
if (raw.length === 0) return fallback;
|
|
70
|
+
return parseDeclared(
|
|
71
|
+
raw,
|
|
72
|
+
(selector) => {
|
|
73
|
+
element.matches(selector);
|
|
74
|
+
return selector;
|
|
75
|
+
},
|
|
76
|
+
fallback
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/utils/frame_coalescer.ts
|
|
81
|
+
var FrameCoalescer = class {
|
|
82
|
+
#frame = null;
|
|
83
|
+
/**
|
|
84
|
+
* Runs `run` on the next frame, unless a frame is already pending — the first
|
|
85
|
+
* request of a burst wins and the rest are dropped. The pending frame is
|
|
86
|
+
* released before `run`, so `run` may request the next one.
|
|
87
|
+
*/
|
|
88
|
+
schedule(run) {
|
|
89
|
+
if (this.#frame !== null) return;
|
|
90
|
+
this.#frame = requestAnimationFrame(() => {
|
|
91
|
+
this.#frame = null;
|
|
92
|
+
run();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Drops the pending frame, and reaches the platform only when there is one.
|
|
97
|
+
*
|
|
98
|
+
* There is no handle value that stands for "nothing pending":
|
|
99
|
+
* `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
|
|
100
|
+
* arrives as a large positive number that the same allocator can hand out, and
|
|
101
|
+
* an idle cancel would then drop a frame belonging to someone else.
|
|
102
|
+
*/
|
|
103
|
+
cancel() {
|
|
104
|
+
if (this.#frame === null) return;
|
|
105
|
+
cancelAnimationFrame(this.#frame);
|
|
106
|
+
this.#frame = null;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
60
110
|
// src/utils/reduced_motion.ts
|
|
61
111
|
function prefersReducedMotion() {
|
|
62
112
|
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
63
113
|
}
|
|
64
114
|
|
|
115
|
+
// src/utils/scroll_source.ts
|
|
116
|
+
function resolveScrollContainer(selector) {
|
|
117
|
+
const match = selector ? document.querySelector(selector) : null;
|
|
118
|
+
return match instanceof HTMLElement ? match : null;
|
|
119
|
+
}
|
|
120
|
+
function resolveScrollSource(selector) {
|
|
121
|
+
return resolveScrollContainer(selector) ?? window;
|
|
122
|
+
}
|
|
123
|
+
function scrollOffset(source) {
|
|
124
|
+
return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
|
|
125
|
+
}
|
|
126
|
+
|
|
65
127
|
// src/utils/before_cache_reset.ts
|
|
66
128
|
var BeforeCacheReset = class _BeforeCacheReset {
|
|
67
129
|
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
@@ -134,8 +196,8 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
134
196
|
};
|
|
135
197
|
static actions = ["toTop"];
|
|
136
198
|
static events = ["change"];
|
|
137
|
-
/**
|
|
138
|
-
#
|
|
199
|
+
/** Coalesces scroll bursts into one measurement per frame. */
|
|
200
|
+
#frames = new FrameCoalescer();
|
|
139
201
|
/** Previous scroll position, for `direction` mode delta detection. */
|
|
140
202
|
#lastScrollY = 0;
|
|
141
203
|
/** Current visibility, tracked to dispatch `change` only on real transitions. */
|
|
@@ -171,16 +233,10 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
171
233
|
#pendingHide = new BlurDeferral(() => {
|
|
172
234
|
if (this.#connected) this.#evaluate();
|
|
173
235
|
});
|
|
174
|
-
#onScroll = () =>
|
|
175
|
-
if (this.#rafId !== null) return;
|
|
176
|
-
this.#rafId = requestAnimationFrame(() => {
|
|
177
|
-
this.#rafId = null;
|
|
178
|
-
this.#evaluate();
|
|
179
|
-
});
|
|
180
|
-
};
|
|
236
|
+
#onScroll = () => this.#frames.schedule(() => this.#evaluate());
|
|
181
237
|
connect() {
|
|
182
|
-
this.#scrollSource = this.#
|
|
183
|
-
this.#lastScrollY = this.#
|
|
238
|
+
this.#scrollSource = resolveScrollSource(this.#rootSelector);
|
|
239
|
+
this.#lastScrollY = scrollOffset(this.#scrollSource);
|
|
184
240
|
this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
185
241
|
this.#evaluate(false);
|
|
186
242
|
this.#connected = true;
|
|
@@ -188,10 +244,7 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
188
244
|
disconnect() {
|
|
189
245
|
this.#connected = false;
|
|
190
246
|
this.#scrollSource.removeEventListener("scroll", this.#onScroll);
|
|
191
|
-
|
|
192
|
-
cancelAnimationFrame(this.#rafId);
|
|
193
|
-
this.#rafId = null;
|
|
194
|
-
}
|
|
247
|
+
this.#frames.cancel();
|
|
195
248
|
this.#pendingHide.releaseAll();
|
|
196
249
|
this.#tabindex.returnAll();
|
|
197
250
|
this.#visible = null;
|
|
@@ -223,11 +276,11 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
223
276
|
}
|
|
224
277
|
/** Validates `root` once so connect never parses a selector that throws. */
|
|
225
278
|
rootValueChanged() {
|
|
226
|
-
this.#rootSelector =
|
|
279
|
+
this.#rootSelector = validSelector(this.element, this.rootValue, "");
|
|
227
280
|
}
|
|
228
281
|
/** Validates `focusSelector` once so `toTop` never parses a selector that throws. */
|
|
229
282
|
focusSelectorValueChanged() {
|
|
230
|
-
this.#focusSelector =
|
|
283
|
+
this.#focusSelector = validSelector(this.element, this.focusSelectorValue, "");
|
|
231
284
|
}
|
|
232
285
|
/** Scrolls the source to the top and, optionally, moves focus to a safe target. */
|
|
233
286
|
toTop() {
|
|
@@ -250,7 +303,7 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
250
303
|
* @stimeoRenderRoot
|
|
251
304
|
*/
|
|
252
305
|
#evaluate(notify = true) {
|
|
253
|
-
const y = this.#
|
|
306
|
+
const y = scrollOffset(this.#scrollSource);
|
|
254
307
|
let nextVisible;
|
|
255
308
|
if (this.modeValue === "direction") {
|
|
256
309
|
if (y <= this.#offset) {
|
|
@@ -280,13 +333,6 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
280
333
|
if (notify) this.dispatch("change", { detail: { visible: next } });
|
|
281
334
|
}
|
|
282
335
|
/** Resolves the scroll source from `root` (falling back to the window). */
|
|
283
|
-
#resolveScrollSource() {
|
|
284
|
-
if (this.#rootSelector) {
|
|
285
|
-
const root = document.querySelector(this.#rootSelector);
|
|
286
|
-
if (root) return root;
|
|
287
|
-
}
|
|
288
|
-
return window;
|
|
289
|
-
}
|
|
290
336
|
/**
|
|
291
337
|
* The focus owner inside the target, or `null` when focus is elsewhere.
|
|
292
338
|
*
|
|
@@ -299,23 +345,6 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
299
345
|
if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
|
|
300
346
|
return null;
|
|
301
347
|
}
|
|
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
|
-
}
|
|
313
|
-
#scrollY() {
|
|
314
|
-
if (this.#scrollSource === window) {
|
|
315
|
-
return window.scrollY ?? window.pageYOffset ?? 0;
|
|
316
|
-
}
|
|
317
|
-
return this.#scrollSource.scrollTop;
|
|
318
|
-
}
|
|
319
348
|
};
|
|
320
349
|
|
|
321
350
|
export { ScrollVisibilityController };
|
|
@@ -2,14 +2,60 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/scrollspy_controller.ts
|
|
4
4
|
|
|
5
|
-
// src/utils/
|
|
6
|
-
function
|
|
7
|
-
if (!selector) return null;
|
|
5
|
+
// src/utils/declared_value.ts
|
|
6
|
+
function parseDeclared(raw, parse, fallback) {
|
|
8
7
|
try {
|
|
9
|
-
return
|
|
8
|
+
return parse(raw);
|
|
10
9
|
} catch {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function validSelector(element, raw, fallback) {
|
|
14
|
+
if (raw.length === 0) return fallback;
|
|
15
|
+
return parseDeclared(
|
|
16
|
+
raw,
|
|
17
|
+
(selector) => {
|
|
18
|
+
element.matches(selector);
|
|
19
|
+
return selector;
|
|
20
|
+
},
|
|
21
|
+
fallback
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/utils/frame_coalescer.ts
|
|
26
|
+
var FrameCoalescer = class {
|
|
27
|
+
#frame = null;
|
|
28
|
+
/**
|
|
29
|
+
* Runs `run` on the next frame, unless a frame is already pending — the first
|
|
30
|
+
* request of a burst wins and the rest are dropped. The pending frame is
|
|
31
|
+
* released before `run`, so `run` may request the next one.
|
|
32
|
+
*/
|
|
33
|
+
schedule(run) {
|
|
34
|
+
if (this.#frame !== null) return;
|
|
35
|
+
this.#frame = requestAnimationFrame(() => {
|
|
36
|
+
this.#frame = null;
|
|
37
|
+
run();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Drops the pending frame, and reaches the platform only when there is one.
|
|
42
|
+
*
|
|
43
|
+
* There is no handle value that stands for "nothing pending":
|
|
44
|
+
* `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
|
|
45
|
+
* arrives as a large positive number that the same allocator can hand out, and
|
|
46
|
+
* an idle cancel would then drop a frame belonging to someone else.
|
|
47
|
+
*/
|
|
48
|
+
cancel() {
|
|
49
|
+
if (this.#frame === null) return;
|
|
50
|
+
cancelAnimationFrame(this.#frame);
|
|
51
|
+
this.#frame = null;
|
|
11
52
|
}
|
|
12
|
-
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// src/utils/intersection_watcher.ts
|
|
56
|
+
function queryRoot(selector) {
|
|
57
|
+
if (!selector) return null;
|
|
58
|
+
return parseDeclared(selector, (raw) => document.querySelector(raw), null);
|
|
13
59
|
}
|
|
14
60
|
var IntersectionWatcher = class {
|
|
15
61
|
#onEntries;
|
|
@@ -112,6 +158,15 @@ function prefersReducedMotion() {
|
|
|
112
158
|
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
113
159
|
}
|
|
114
160
|
|
|
161
|
+
// src/utils/scroll_source.ts
|
|
162
|
+
function resolveScrollContainer(selector) {
|
|
163
|
+
const match = selector ? document.querySelector(selector) : null;
|
|
164
|
+
return match instanceof HTMLElement ? match : null;
|
|
165
|
+
}
|
|
166
|
+
function scrollOffset(source) {
|
|
167
|
+
return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
|
|
168
|
+
}
|
|
169
|
+
|
|
115
170
|
// src/controllers/scrollspy_controller.ts
|
|
116
171
|
var ANCHOR_ATTRIBUTES = ["href", "data-href"];
|
|
117
172
|
var ScrollspyController = class extends Controller {
|
|
@@ -158,8 +213,10 @@ var ScrollspyController = class extends Controller {
|
|
|
158
213
|
* rather than from whatever the selector resolves to now.
|
|
159
214
|
*/
|
|
160
215
|
#scrollSource = null;
|
|
161
|
-
/**
|
|
162
|
-
#
|
|
216
|
+
/** Coalesces a scroll burst into one measurement per frame. */
|
|
217
|
+
#frames = new FrameCoalescer();
|
|
218
|
+
/** The validated `rootSelector`, or `""` when the declaration cannot be parsed. */
|
|
219
|
+
#rootSelector = "";
|
|
163
220
|
/** Watches the link targets' anchor attributes for an in-place morph rewrite. */
|
|
164
221
|
#anchorObserver = null;
|
|
165
222
|
/** True while a coalesced observation rebuild is queued; see {@link #scheduleResync}. */
|
|
@@ -176,10 +233,7 @@ var ScrollspyController = class extends Controller {
|
|
|
176
233
|
this.#anchorObserver?.disconnect();
|
|
177
234
|
this.#anchorObserver = null;
|
|
178
235
|
this.#detachScrollListener();
|
|
179
|
-
|
|
180
|
-
cancelAnimationFrame(this.#frame);
|
|
181
|
-
this.#frame = null;
|
|
182
|
-
}
|
|
236
|
+
this.#frames.cancel();
|
|
183
237
|
this.#intersectionStates.clear();
|
|
184
238
|
this.#activeSectionId = "";
|
|
185
239
|
this.#rootElement = null;
|
|
@@ -196,6 +250,7 @@ var ScrollspyController = class extends Controller {
|
|
|
196
250
|
this.#initializeObserver();
|
|
197
251
|
}
|
|
198
252
|
rootSelectorValueChanged() {
|
|
253
|
+
this.#rootSelector = validSelector(this.element, this.rootSelectorValue, "");
|
|
199
254
|
if (!this.#isConnected) return;
|
|
200
255
|
this.#initializeObserver();
|
|
201
256
|
}
|
|
@@ -239,7 +294,7 @@ var ScrollspyController = class extends Controller {
|
|
|
239
294
|
* only rewrites attributes, so Stimulus fires no target callback: without
|
|
240
295
|
* this, a link re-pointed from `#intro` to `#faq` would keep the controller
|
|
241
296
|
* observing `#intro` for the rest of the page's life. The filter is exactly
|
|
242
|
-
*
|
|
297
|
+
* `href` and `data-href`; guarding on `MutationObserver` keeps the
|
|
243
298
|
* controller usable where the API is absent, matching `IntersectionWatcher`'s
|
|
244
299
|
* own support guard.
|
|
245
300
|
*/
|
|
@@ -289,10 +344,10 @@ var ScrollspyController = class extends Controller {
|
|
|
289
344
|
const offset = this.#offset;
|
|
290
345
|
if (rootElement) {
|
|
291
346
|
const containerRect = rootElement.getBoundingClientRect();
|
|
292
|
-
const scrollPosition = rootElement
|
|
347
|
+
const scrollPosition = scrollOffset(rootElement) + (targetRect.top - containerRect.top) - offset;
|
|
293
348
|
rootElement.scrollTo({ top: scrollPosition, behavior });
|
|
294
349
|
} else {
|
|
295
|
-
const scrollPosition = window
|
|
350
|
+
const scrollPosition = scrollOffset(window) + targetRect.top - offset;
|
|
296
351
|
window.scrollTo({ top: scrollPosition, behavior });
|
|
297
352
|
}
|
|
298
353
|
if (this.focusSectionValue) this.#focusSection(targetElement);
|
|
@@ -327,19 +382,14 @@ var ScrollspyController = class extends Controller {
|
|
|
327
382
|
/**
|
|
328
383
|
* Resolves `rootSelector` to a scrollable element.
|
|
329
384
|
*
|
|
330
|
-
* @returns The container, or `null` meaning "spy the viewport" when the
|
|
331
|
-
* is empty, matches nothing, matches a non-HTML element
|
|
332
|
-
*
|
|
333
|
-
*
|
|
385
|
+
* @returns The container, or `null` meaning "spy the viewport" when the
|
|
386
|
+
* declaration is empty, matches nothing, or matches a non-HTML element. A typo
|
|
387
|
+
* reads as empty: the declaration is validated once when it changes, so a
|
|
388
|
+
* selector that cannot be parsed degrades to viewport spying rather than
|
|
389
|
+
* leaving the controller inert.
|
|
334
390
|
*/
|
|
335
391
|
#queryRootElement() {
|
|
336
|
-
|
|
337
|
-
try {
|
|
338
|
-
const root = document.querySelector(this.rootSelectorValue);
|
|
339
|
-
return root instanceof HTMLElement ? root : null;
|
|
340
|
-
} catch {
|
|
341
|
-
return null;
|
|
342
|
-
}
|
|
392
|
+
return resolveScrollContainer(this.#rootSelector);
|
|
343
393
|
}
|
|
344
394
|
/**
|
|
345
395
|
* Re-evaluates once per frame while the reader scrolls.
|
|
@@ -354,13 +404,7 @@ var ScrollspyController = class extends Controller {
|
|
|
354
404
|
* end in {@link #evaluateActiveSection} — which measures section rects and the
|
|
355
405
|
* root's top edge at that instant — they converge on the same answer.
|
|
356
406
|
*/
|
|
357
|
-
#onScroll = () =>
|
|
358
|
-
if (this.#frame !== null) return;
|
|
359
|
-
this.#frame = requestAnimationFrame(() => {
|
|
360
|
-
this.#frame = null;
|
|
361
|
-
this.#evaluateActiveSection();
|
|
362
|
-
});
|
|
363
|
-
};
|
|
407
|
+
#onScroll = () => this.#frames.schedule(() => this.#evaluateActiveSection());
|
|
364
408
|
/**
|
|
365
409
|
* Points the `scroll` listener at whatever the reader actually scrolls: the
|
|
366
410
|
* resolved root, or the window when the viewport is spied.
|
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/smart_sticky_header_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/declared_value.ts
|
|
6
|
+
function parseDeclared(raw, parse, fallback) {
|
|
7
|
+
try {
|
|
8
|
+
return parse(raw);
|
|
9
|
+
} catch {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function validSelector(element, raw, fallback) {
|
|
14
|
+
if (raw.length === 0) return fallback;
|
|
15
|
+
return parseDeclared(
|
|
16
|
+
raw,
|
|
17
|
+
(selector) => {
|
|
18
|
+
element.matches(selector);
|
|
19
|
+
return selector;
|
|
20
|
+
},
|
|
21
|
+
fallback
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/utils/frame_coalescer.ts
|
|
26
|
+
var FrameCoalescer = class {
|
|
27
|
+
#frame = null;
|
|
28
|
+
/**
|
|
29
|
+
* Runs `run` on the next frame, unless a frame is already pending — the first
|
|
30
|
+
* request of a burst wins and the rest are dropped. The pending frame is
|
|
31
|
+
* released before `run`, so `run` may request the next one.
|
|
32
|
+
*/
|
|
33
|
+
schedule(run) {
|
|
34
|
+
if (this.#frame !== null) return;
|
|
35
|
+
this.#frame = requestAnimationFrame(() => {
|
|
36
|
+
this.#frame = null;
|
|
37
|
+
run();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Drops the pending frame, and reaches the platform only when there is one.
|
|
42
|
+
*
|
|
43
|
+
* There is no handle value that stands for "nothing pending":
|
|
44
|
+
* `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
|
|
45
|
+
* arrives as a large positive number that the same allocator can hand out, and
|
|
46
|
+
* an idle cancel would then drop a frame belonging to someone else.
|
|
47
|
+
*/
|
|
48
|
+
cancel() {
|
|
49
|
+
if (this.#frame === null) return;
|
|
50
|
+
cancelAnimationFrame(this.#frame);
|
|
51
|
+
this.#frame = null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// src/utils/scroll_source.ts
|
|
56
|
+
function resolveScrollContainer(selector) {
|
|
57
|
+
const match = selector ? document.querySelector(selector) : null;
|
|
58
|
+
return match instanceof HTMLElement ? match : null;
|
|
59
|
+
}
|
|
60
|
+
function resolveScrollSource(selector) {
|
|
61
|
+
return resolveScrollContainer(selector) ?? window;
|
|
62
|
+
}
|
|
63
|
+
function scrollOffset(source) {
|
|
64
|
+
return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
|
|
65
|
+
}
|
|
66
|
+
|
|
3
67
|
// src/controllers/smart_sticky_header_controller.ts
|
|
4
68
|
var DEFAULT_OFFSET = 80;
|
|
5
69
|
var SmartStickyHeaderController = class extends Controller {
|
|
@@ -10,7 +74,8 @@ var SmartStickyHeaderController = class extends Controller {
|
|
|
10
74
|
};
|
|
11
75
|
static events = ["change"];
|
|
12
76
|
#connected = false;
|
|
13
|
-
|
|
77
|
+
/** Coalesces scroll bursts into one measurement per frame. */
|
|
78
|
+
#frames = new FrameCoalescer();
|
|
14
79
|
/** The scroll source resolved at connect — disconnect must unbind the SAME node. */
|
|
15
80
|
#scrollerEl = window;
|
|
16
81
|
/** The validated `containerSelector`, or `""` when the declaration cannot be parsed. */
|
|
@@ -27,13 +92,7 @@ var SmartStickyHeaderController = class extends Controller {
|
|
|
27
92
|
get #offset() {
|
|
28
93
|
return Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;
|
|
29
94
|
}
|
|
30
|
-
#onScroll = () =>
|
|
31
|
-
if (this.#frame !== null) return;
|
|
32
|
-
this.#frame = requestAnimationFrame(() => {
|
|
33
|
-
this.#frame = null;
|
|
34
|
-
this.#measure();
|
|
35
|
-
});
|
|
36
|
-
};
|
|
95
|
+
#onScroll = () => this.#frames.schedule(() => this.#measure());
|
|
37
96
|
/**
|
|
38
97
|
* Focus inside a hidden header must reveal it (WCAG 2.4.7 / 2.4.11); the
|
|
39
98
|
* hold while focus *stays* inside is the `#apply` hide invariant.
|
|
@@ -41,7 +100,7 @@ var SmartStickyHeaderController = class extends Controller {
|
|
|
41
100
|
#onFocusin = () => this.#apply(false);
|
|
42
101
|
/** Validates `containerSelector` once so connect never parses a selector that throws. */
|
|
43
102
|
containerSelectorValueChanged() {
|
|
44
|
-
this.#containerSelector =
|
|
103
|
+
this.#containerSelector = validSelector(this.element, this.containerSelectorValue, "");
|
|
45
104
|
}
|
|
46
105
|
/** Re-decides when application code (or a Turbo morph) changes `offset` at runtime. */
|
|
47
106
|
offsetValueChanged() {
|
|
@@ -49,8 +108,8 @@ var SmartStickyHeaderController = class extends Controller {
|
|
|
49
108
|
}
|
|
50
109
|
connect() {
|
|
51
110
|
this.#hidden = null;
|
|
52
|
-
this.#scrollerEl = this.#
|
|
53
|
-
this.#lastY = this.#
|
|
111
|
+
this.#scrollerEl = resolveScrollSource(this.#containerSelector);
|
|
112
|
+
this.#lastY = scrollOffset(this.#scrollerEl);
|
|
54
113
|
this.#scrollerEl.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
55
114
|
this.element.addEventListener("focusin", this.#onFocusin);
|
|
56
115
|
this.#apply(false, false);
|
|
@@ -60,34 +119,10 @@ var SmartStickyHeaderController = class extends Controller {
|
|
|
60
119
|
this.#connected = false;
|
|
61
120
|
this.#scrollerEl.removeEventListener("scroll", this.#onScroll);
|
|
62
121
|
this.element.removeEventListener("focusin", this.#onFocusin);
|
|
63
|
-
|
|
64
|
-
this.#frame = null;
|
|
65
|
-
}
|
|
66
|
-
/** Resolves the scroll source: the `containerSelector` match, else the window. */
|
|
67
|
-
#resolveScroller() {
|
|
68
|
-
if (this.#containerSelector) {
|
|
69
|
-
const container = document.querySelector(this.#containerSelector);
|
|
70
|
-
if (container) return container;
|
|
71
|
-
}
|
|
72
|
-
return window;
|
|
73
|
-
}
|
|
74
|
-
/** Returns `declared` when it parses as a selector, and `""` when it does not. */
|
|
75
|
-
#validSelector(declared) {
|
|
76
|
-
if (declared.length > 0) {
|
|
77
|
-
try {
|
|
78
|
-
this.element.matches(declared);
|
|
79
|
-
return declared;
|
|
80
|
-
} catch {
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return "";
|
|
84
|
-
}
|
|
85
|
-
get #scrollY() {
|
|
86
|
-
const scroller = this.#scrollerEl;
|
|
87
|
-
return scroller === window ? window.scrollY : scroller.scrollTop;
|
|
122
|
+
this.#frames.cancel();
|
|
88
123
|
}
|
|
89
124
|
#measure() {
|
|
90
|
-
const y = this.#
|
|
125
|
+
const y = scrollOffset(this.#scrollerEl);
|
|
91
126
|
if (y <= this.#offset) {
|
|
92
127
|
this.#lastY = y;
|
|
93
128
|
this.#apply(false);
|
|
@@ -8,10 +8,15 @@ var LayoutObserver = class {
|
|
|
8
8
|
#resizeObserverFactory;
|
|
9
9
|
#resizeObserver = null;
|
|
10
10
|
#observingViewport = false;
|
|
11
|
+
#loadContainer = null;
|
|
11
12
|
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
12
13
|
#handleViewportResize = () => {
|
|
13
14
|
this.#callback();
|
|
14
15
|
};
|
|
16
|
+
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
17
|
+
#handleDescendantLoad = () => {
|
|
18
|
+
this.#callback();
|
|
19
|
+
};
|
|
15
20
|
constructor(callback, options = {}) {
|
|
16
21
|
this.#callback = callback;
|
|
17
22
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
@@ -47,14 +52,35 @@ var LayoutObserver = class {
|
|
|
47
52
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
48
53
|
}
|
|
49
54
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
55
|
+
* Starts reporting a `load` from anywhere inside `container` — an image or a
|
|
56
|
+
* frame settling changes the box it sits in, and it measures as zero high until
|
|
57
|
+
* then. `load` does not bubble, so the subscription is a capture-phase listener
|
|
58
|
+
* on the container itself and nothing the caller spells.
|
|
59
|
+
*
|
|
60
|
+
* **One container at a time.** A further call moves the observation, so a widget
|
|
61
|
+
* whose content element is swapped at runtime releases the element it let go by
|
|
62
|
+
* naming the new one — there is no second place for the release to drift from.
|
|
63
|
+
*/
|
|
64
|
+
observeDescendantLoads(container) {
|
|
65
|
+
this.unobserveDescendantLoads();
|
|
66
|
+
this.#loadContainer = container;
|
|
67
|
+
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
68
|
+
}
|
|
69
|
+
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
70
|
+
unobserveDescendantLoads() {
|
|
71
|
+
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
72
|
+
this.#loadContainer = null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and removes
|
|
76
|
+
* the viewport and descendant-load listeners. Safe to call multiple times. Call
|
|
77
|
+
* this from a controller's `disconnect()`.
|
|
53
78
|
*/
|
|
54
79
|
disconnect() {
|
|
55
80
|
this.#resizeObserver?.disconnect();
|
|
56
81
|
this.#resizeObserver = null;
|
|
57
82
|
this.unobserveViewport();
|
|
83
|
+
this.unobserveDescendantLoads();
|
|
58
84
|
}
|
|
59
85
|
};
|
|
60
86
|
|
|
@@ -2,6 +2,15 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/sticky_observer_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/declared_value.ts
|
|
6
|
+
function parseDeclared(raw, parse, fallback) {
|
|
7
|
+
try {
|
|
8
|
+
return parse(raw);
|
|
9
|
+
} catch {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
// src/utils/intersection_watcher.ts
|
|
6
15
|
function isBeforeRootStart(entry) {
|
|
7
16
|
const rect = entry.boundingClientRect;
|
|
@@ -11,11 +20,7 @@ function isBeforeRootStart(entry) {
|
|
|
11
20
|
}
|
|
12
21
|
function queryRoot(selector) {
|
|
13
22
|
if (!selector) return null;
|
|
14
|
-
|
|
15
|
-
return document.querySelector(selector);
|
|
16
|
-
} catch {
|
|
17
|
-
}
|
|
18
|
-
return null;
|
|
23
|
+
return parseDeclared(selector, (raw) => document.querySelector(raw), null);
|
|
19
24
|
}
|
|
20
25
|
var IntersectionWatcher = class {
|
|
21
26
|
#onEntries;
|
|
@@ -138,6 +138,30 @@ var DetachGate = class _DetachGate {
|
|
|
138
138
|
}
|
|
139
139
|
};
|
|
140
140
|
|
|
141
|
+
// src/utils/listener_set.ts
|
|
142
|
+
var ListenerSet = class {
|
|
143
|
+
/** The generation every `add` joins until the next `dispose()`. */
|
|
144
|
+
#abort = new AbortController();
|
|
145
|
+
/**
|
|
146
|
+
* Attaches `handler` to the open generation, exactly as the caller spelled it.
|
|
147
|
+
*
|
|
148
|
+
* The set supplies the signal, so `options` carries everything else the DOM
|
|
149
|
+
* accepts — `capture` included, which has to match at release time and no
|
|
150
|
+
* longer has a second place to drift from.
|
|
151
|
+
*/
|
|
152
|
+
add(target, type, handler, options) {
|
|
153
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Releases every listener of the open generation, synchronously, and opens the
|
|
157
|
+
* next one. Idempotent, and safe before anything has been added.
|
|
158
|
+
*/
|
|
159
|
+
dispose() {
|
|
160
|
+
this.#abort.abort();
|
|
161
|
+
this.#abort = new AbortController();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
141
165
|
// src/utils/safe_timeout.ts
|
|
142
166
|
var TimerRegistry = class {
|
|
143
167
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -206,6 +230,7 @@ var SubmitOnceController = class extends Controller {
|
|
|
206
230
|
static events = ["start", "end", "reconcile"];
|
|
207
231
|
#timers = new SafeTimeout();
|
|
208
232
|
#gate = new DetachGate();
|
|
233
|
+
#listeners = new ListenerSet();
|
|
209
234
|
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
210
235
|
#sessions = /* @__PURE__ */ new Map();
|
|
211
236
|
#disabled = new AttributeLease("disabled");
|
|
@@ -230,14 +255,12 @@ var SubmitOnceController = class extends Controller {
|
|
|
230
255
|
connect() {
|
|
231
256
|
this.#gate.cancel();
|
|
232
257
|
this.#beforeCache.activate();
|
|
233
|
-
this.element
|
|
234
|
-
this.element
|
|
235
|
-
this.element
|
|
258
|
+
this.#listeners.add(this.element, "submit", this.#onNativeSubmit, { capture: true });
|
|
259
|
+
this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
|
|
260
|
+
this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
|
|
236
261
|
}
|
|
237
262
|
disconnect() {
|
|
238
|
-
this.
|
|
239
|
-
this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
240
|
-
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
263
|
+
this.#listeners.dispose();
|
|
241
264
|
this.#beforeCache.deactivate();
|
|
242
265
|
this.#gate.disconnected(this, () => this.#teardown());
|
|
243
266
|
}
|