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
|
@@ -31,16 +31,51 @@ var BeforeCacheReset = class _BeforeCacheReset {
|
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
// src/utils/frame_coalescer.ts
|
|
35
|
+
var FrameCoalescer = class {
|
|
36
|
+
#frame = null;
|
|
37
|
+
/**
|
|
38
|
+
* Runs `run` on the next frame, unless a frame is already pending — the first
|
|
39
|
+
* request of a burst wins and the rest are dropped. The pending frame is
|
|
40
|
+
* released before `run`, so `run` may request the next one.
|
|
41
|
+
*/
|
|
42
|
+
schedule(run) {
|
|
43
|
+
if (this.#frame !== null) return;
|
|
44
|
+
this.#frame = requestAnimationFrame(() => {
|
|
45
|
+
this.#frame = null;
|
|
46
|
+
run();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Drops the pending frame, and reaches the platform only when there is one.
|
|
51
|
+
*
|
|
52
|
+
* There is no handle value that stands for "nothing pending":
|
|
53
|
+
* `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
|
|
54
|
+
* arrives as a large positive number that the same allocator can hand out, and
|
|
55
|
+
* an idle cancel would then drop a frame belonging to someone else.
|
|
56
|
+
*/
|
|
57
|
+
cancel() {
|
|
58
|
+
if (this.#frame === null) return;
|
|
59
|
+
cancelAnimationFrame(this.#frame);
|
|
60
|
+
this.#frame = null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
34
64
|
// src/utils/layout_observer.ts
|
|
35
65
|
var LayoutObserver = class {
|
|
36
66
|
#callback;
|
|
37
67
|
#resizeObserverFactory;
|
|
38
68
|
#resizeObserver = null;
|
|
39
69
|
#observingViewport = false;
|
|
70
|
+
#loadContainer = null;
|
|
40
71
|
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
41
72
|
#handleViewportResize = () => {
|
|
42
73
|
this.#callback();
|
|
43
74
|
};
|
|
75
|
+
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
76
|
+
#handleDescendantLoad = () => {
|
|
77
|
+
this.#callback();
|
|
78
|
+
};
|
|
44
79
|
constructor(callback, options = {}) {
|
|
45
80
|
this.#callback = callback;
|
|
46
81
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
@@ -76,14 +111,35 @@ var LayoutObserver = class {
|
|
|
76
111
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
77
112
|
}
|
|
78
113
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
114
|
+
* Starts reporting a `load` from anywhere inside `container` — an image or a
|
|
115
|
+
* frame settling changes the box it sits in, and it measures as zero high until
|
|
116
|
+
* then. `load` does not bubble, so the subscription is a capture-phase listener
|
|
117
|
+
* on the container itself and nothing the caller spells.
|
|
118
|
+
*
|
|
119
|
+
* **One container at a time.** A further call moves the observation, so a widget
|
|
120
|
+
* whose content element is swapped at runtime releases the element it let go by
|
|
121
|
+
* naming the new one — there is no second place for the release to drift from.
|
|
122
|
+
*/
|
|
123
|
+
observeDescendantLoads(container) {
|
|
124
|
+
this.unobserveDescendantLoads();
|
|
125
|
+
this.#loadContainer = container;
|
|
126
|
+
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
127
|
+
}
|
|
128
|
+
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
129
|
+
unobserveDescendantLoads() {
|
|
130
|
+
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
131
|
+
this.#loadContainer = null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and removes
|
|
135
|
+
* the viewport and descendant-load listeners. Safe to call multiple times. Call
|
|
136
|
+
* this from a controller's `disconnect()`.
|
|
82
137
|
*/
|
|
83
138
|
disconnect() {
|
|
84
139
|
this.#resizeObserver?.disconnect();
|
|
85
140
|
this.#resizeObserver = null;
|
|
86
141
|
this.unobserveViewport();
|
|
142
|
+
this.unobserveDescendantLoads();
|
|
87
143
|
}
|
|
88
144
|
};
|
|
89
145
|
|
|
@@ -148,18 +204,13 @@ var ReadingProgressController = class extends Controller {
|
|
|
148
204
|
/** The article's own box and the viewport: either changes the span. */
|
|
149
205
|
#layout = new LayoutObserver(() => this.#onScroll());
|
|
150
206
|
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
151
|
-
|
|
207
|
+
/** Coalesces scroll bursts, and the connect baseline, into one frame. */
|
|
208
|
+
#frames = new FrameCoalescer();
|
|
152
209
|
/** Last published progress, so `change`/`complete` fire only on movement. */
|
|
153
210
|
#progress = -1;
|
|
154
211
|
/** False until the connect frame has run: `complete` needs real reading. */
|
|
155
212
|
#baselined = false;
|
|
156
|
-
#onScroll = () =>
|
|
157
|
-
if (this.#frame !== null) return;
|
|
158
|
-
this.#frame = requestAnimationFrame(() => {
|
|
159
|
-
this.#frame = null;
|
|
160
|
-
this.#measure();
|
|
161
|
-
});
|
|
162
|
-
};
|
|
213
|
+
#onScroll = () => this.#frames.schedule(() => this.#measure());
|
|
163
214
|
connect() {
|
|
164
215
|
this.#progress = -1;
|
|
165
216
|
this.#baselined = false;
|
|
@@ -168,8 +219,7 @@ var ReadingProgressController = class extends Controller {
|
|
|
168
219
|
this.#layout.observeViewport();
|
|
169
220
|
this.#beforeCache.activate();
|
|
170
221
|
this.#measure();
|
|
171
|
-
this.#
|
|
172
|
-
this.#frame = null;
|
|
222
|
+
this.#frames.schedule(() => {
|
|
173
223
|
this.#measure();
|
|
174
224
|
this.#baselined = true;
|
|
175
225
|
});
|
|
@@ -178,7 +228,7 @@ var ReadingProgressController = class extends Controller {
|
|
|
178
228
|
window.removeEventListener("scroll", this.#onScroll, { capture: true });
|
|
179
229
|
this.#layout.disconnect();
|
|
180
230
|
this.#beforeCache.deactivate();
|
|
181
|
-
this.#
|
|
231
|
+
this.#frames.cancel();
|
|
182
232
|
this.#lease.returnAll();
|
|
183
233
|
}
|
|
184
234
|
/**
|
|
@@ -189,14 +239,10 @@ var ReadingProgressController = class extends Controller {
|
|
|
189
239
|
* value that has already been handed back.
|
|
190
240
|
*/
|
|
191
241
|
#rewindForCache() {
|
|
192
|
-
this.#
|
|
242
|
+
this.#frames.cancel();
|
|
193
243
|
this.#lease.returnAll();
|
|
194
244
|
this.#progress = -1;
|
|
195
245
|
}
|
|
196
|
-
#cancelFrame() {
|
|
197
|
-
if (this.#frame !== null) cancelAnimationFrame(this.#frame);
|
|
198
|
-
this.#frame = null;
|
|
199
|
-
}
|
|
200
246
|
/** Computes and publishes the progress; emits on movement only. */
|
|
201
247
|
#measure() {
|
|
202
248
|
const rect = this.element.getBoundingClientRect();
|
|
@@ -31,6 +31,40 @@ var BeforeCacheReset = class _BeforeCacheReset {
|
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
// src/utils/intl_format.ts
|
|
35
|
+
var formatters = /* @__PURE__ */ new WeakMap();
|
|
36
|
+
function intlFormatter(factory, locale, options, fallbackLocale) {
|
|
37
|
+
const cache = cacheFor(factory);
|
|
38
|
+
const key = cacheKey(locale, options, fallbackLocale);
|
|
39
|
+
if (cache.has(key)) return cache.get(key);
|
|
40
|
+
const formatter = construct(factory, locale, options);
|
|
41
|
+
cache.set(key, formatter);
|
|
42
|
+
return formatter;
|
|
43
|
+
}
|
|
44
|
+
function cacheFor(factory) {
|
|
45
|
+
const existing = formatters.get(factory);
|
|
46
|
+
if (existing) return existing;
|
|
47
|
+
const created = /* @__PURE__ */ new Map();
|
|
48
|
+
formatters.set(factory, created);
|
|
49
|
+
return created;
|
|
50
|
+
}
|
|
51
|
+
function cacheKey(locale, options, fallbackLocale) {
|
|
52
|
+
return JSON.stringify([locale, options, fallbackLocale]);
|
|
53
|
+
}
|
|
54
|
+
function construct(factory, locale, options, fallbackLocale) {
|
|
55
|
+
try {
|
|
56
|
+
return new factory(locale, options);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (!(error instanceof RangeError)) throw error;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/utils/locale.ts
|
|
64
|
+
function resolveLocale(element, declared = "") {
|
|
65
|
+
return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
34
68
|
// src/utils/microtask_coalescer.ts
|
|
35
69
|
var MicrotaskCoalescer = class {
|
|
36
70
|
#run;
|
|
@@ -128,6 +162,7 @@ var UNITS = [
|
|
|
128
162
|
{ limit: 31557600, unit: "month", ms: 26298e5 },
|
|
129
163
|
YEAR_SCALE
|
|
130
164
|
];
|
|
165
|
+
var RELATIVE_OPTIONS = { numeric: "auto" };
|
|
131
166
|
var RelativeTimeController = class extends Controller {
|
|
132
167
|
static values = {
|
|
133
168
|
locale: { type: String, default: "" },
|
|
@@ -235,15 +270,11 @@ var RelativeTimeController = class extends Controller {
|
|
|
235
270
|
* when the runtime rejects that locale.
|
|
236
271
|
*/
|
|
237
272
|
get #formatter() {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
/** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
|
|
245
|
-
get #locale() {
|
|
246
|
-
return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
273
|
+
return intlFormatter(
|
|
274
|
+
Intl.RelativeTimeFormat,
|
|
275
|
+
resolveLocale(this.element, this.localeValue),
|
|
276
|
+
RELATIVE_OPTIONS
|
|
277
|
+
);
|
|
247
278
|
}
|
|
248
279
|
};
|
|
249
280
|
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/reset_before_cache_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
|
+
|
|
3
25
|
// src/controllers/reset_before_cache_controller.ts
|
|
4
26
|
var STATELESS_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "reset", "button", "image"]);
|
|
5
27
|
function restoreField(element) {
|
|
@@ -43,16 +65,7 @@ var ResetBeforeCacheController = class extends Controller {
|
|
|
43
65
|
* silently taking the sweep down.
|
|
44
66
|
*/
|
|
45
67
|
scopeValueChanged() {
|
|
46
|
-
|
|
47
|
-
if (selector.length > 0) {
|
|
48
|
-
try {
|
|
49
|
-
this.element.matches(selector);
|
|
50
|
-
this.#scopeSelector = selector;
|
|
51
|
-
return;
|
|
52
|
-
} catch {
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
this.#scopeSelector = "";
|
|
68
|
+
this.#scopeSelector = validSelector(this.element, this.scopeValue, "");
|
|
56
69
|
}
|
|
57
70
|
connect() {
|
|
58
71
|
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
@@ -8,6 +8,12 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
8
8
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// src/utils/event_owner.ts
|
|
12
|
+
function ownerIndex(candidates, node) {
|
|
13
|
+
if (!(node instanceof Node)) return -1;
|
|
14
|
+
return candidates.findIndex((candidate) => candidate.contains(node));
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
// src/utils/microtask_coalescer.ts
|
|
12
18
|
var MicrotaskCoalescer = class {
|
|
13
19
|
#run;
|
|
@@ -154,8 +160,7 @@ var ResizableController = class extends Controller {
|
|
|
154
160
|
const panes = [];
|
|
155
161
|
if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
|
|
156
162
|
if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
|
|
157
|
-
const
|
|
158
|
-
const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
|
|
163
|
+
const current = ownerIndex(panes, event.target);
|
|
159
164
|
const next = panes[(current + 1) % panes.length];
|
|
160
165
|
if (!next) return;
|
|
161
166
|
event.preventDefault();
|
|
@@ -16,6 +16,12 @@ function hasModifierChord(event) {
|
|
|
16
16
|
return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// src/utils/event_owner.ts
|
|
20
|
+
function ownerIndex(candidates, node) {
|
|
21
|
+
if (!(node instanceof Node)) return -1;
|
|
22
|
+
return candidates.findIndex((candidate) => candidate.contains(node));
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
// src/utils/focus_candidate.ts
|
|
20
26
|
function inheritsFieldsetDisabled(control) {
|
|
21
27
|
let fieldset = control.closest("fieldset[disabled]");
|
|
@@ -182,9 +188,7 @@ var RovingController = class extends Controller {
|
|
|
182
188
|
};
|
|
183
189
|
/** Resolves the item index owning an event target (the item or a descendant). */
|
|
184
190
|
#indexOf(target) {
|
|
185
|
-
|
|
186
|
-
if (!node) return -1;
|
|
187
|
-
return this.itemTargets.findIndex((item) => item === node || item.contains(node));
|
|
191
|
+
return ownerIndex(this.itemTargets, target);
|
|
188
192
|
}
|
|
189
193
|
/** Makes `index` the tab stop (optionally focusing it), emitting `change` once. */
|
|
190
194
|
#activate(index, focus) {
|
|
@@ -150,16 +150,51 @@ function hasTabStop(root) {
|
|
|
150
150
|
return firstTabStop(root) !== null;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
// src/utils/frame_coalescer.ts
|
|
154
|
+
var FrameCoalescer = class {
|
|
155
|
+
#frame = null;
|
|
156
|
+
/**
|
|
157
|
+
* Runs `run` on the next frame, unless a frame is already pending — the first
|
|
158
|
+
* request of a burst wins and the rest are dropped. The pending frame is
|
|
159
|
+
* released before `run`, so `run` may request the next one.
|
|
160
|
+
*/
|
|
161
|
+
schedule(run) {
|
|
162
|
+
if (this.#frame !== null) return;
|
|
163
|
+
this.#frame = requestAnimationFrame(() => {
|
|
164
|
+
this.#frame = null;
|
|
165
|
+
run();
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Drops the pending frame, and reaches the platform only when there is one.
|
|
170
|
+
*
|
|
171
|
+
* There is no handle value that stands for "nothing pending":
|
|
172
|
+
* `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
|
|
173
|
+
* arrives as a large positive number that the same allocator can hand out, and
|
|
174
|
+
* an idle cancel would then drop a frame belonging to someone else.
|
|
175
|
+
*/
|
|
176
|
+
cancel() {
|
|
177
|
+
if (this.#frame === null) return;
|
|
178
|
+
cancelAnimationFrame(this.#frame);
|
|
179
|
+
this.#frame = null;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
153
183
|
// src/utils/layout_observer.ts
|
|
154
184
|
var LayoutObserver = class {
|
|
155
185
|
#callback;
|
|
156
186
|
#resizeObserverFactory;
|
|
157
187
|
#resizeObserver = null;
|
|
158
188
|
#observingViewport = false;
|
|
189
|
+
#loadContainer = null;
|
|
159
190
|
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
160
191
|
#handleViewportResize = () => {
|
|
161
192
|
this.#callback();
|
|
162
193
|
};
|
|
194
|
+
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
195
|
+
#handleDescendantLoad = () => {
|
|
196
|
+
this.#callback();
|
|
197
|
+
};
|
|
163
198
|
constructor(callback, options = {}) {
|
|
164
199
|
this.#callback = callback;
|
|
165
200
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
@@ -195,14 +230,35 @@ var LayoutObserver = class {
|
|
|
195
230
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
196
231
|
}
|
|
197
232
|
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
233
|
+
* Starts reporting a `load` from anywhere inside `container` — an image or a
|
|
234
|
+
* frame settling changes the box it sits in, and it measures as zero high until
|
|
235
|
+
* then. `load` does not bubble, so the subscription is a capture-phase listener
|
|
236
|
+
* on the container itself and nothing the caller spells.
|
|
237
|
+
*
|
|
238
|
+
* **One container at a time.** A further call moves the observation, so a widget
|
|
239
|
+
* whose content element is swapped at runtime releases the element it let go by
|
|
240
|
+
* naming the new one — there is no second place for the release to drift from.
|
|
241
|
+
*/
|
|
242
|
+
observeDescendantLoads(container) {
|
|
243
|
+
this.unobserveDescendantLoads();
|
|
244
|
+
this.#loadContainer = container;
|
|
245
|
+
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
246
|
+
}
|
|
247
|
+
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
248
|
+
unobserveDescendantLoads() {
|
|
249
|
+
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
250
|
+
this.#loadContainer = null;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and removes
|
|
254
|
+
* the viewport and descendant-load listeners. Safe to call multiple times. Call
|
|
255
|
+
* this from a controller's `disconnect()`.
|
|
201
256
|
*/
|
|
202
257
|
disconnect() {
|
|
203
258
|
this.#resizeObserver?.disconnect();
|
|
204
259
|
this.#resizeObserver = null;
|
|
205
260
|
this.unobserveViewport();
|
|
261
|
+
this.unobserveDescendantLoads();
|
|
206
262
|
}
|
|
207
263
|
};
|
|
208
264
|
|
|
@@ -359,20 +415,14 @@ var ScrollAreaController = class extends Controller {
|
|
|
359
415
|
#observedNameIds = /* @__PURE__ */ new Set();
|
|
360
416
|
#observedNameSources = [];
|
|
361
417
|
#fonts = null;
|
|
362
|
-
|
|
418
|
+
/** Coalesces scroll bursts into one position sync per frame. */
|
|
419
|
+
#frames = new FrameCoalescer();
|
|
363
420
|
#overflowing = false;
|
|
364
421
|
#lastEdge = null;
|
|
365
422
|
#ownedHostMutations = /* @__PURE__ */ new Map();
|
|
366
|
-
#onScroll = () => {
|
|
367
|
-
|
|
368
|
-
this.#
|
|
369
|
-
this.#scrollFrame = null;
|
|
370
|
-
const viewport = this.#viewport;
|
|
371
|
-
if (viewport) this.#syncPosition(viewport, this.#overflowing);
|
|
372
|
-
});
|
|
373
|
-
};
|
|
374
|
-
#onLoad = () => {
|
|
375
|
-
this.#refresh();
|
|
423
|
+
#onScroll = (event) => {
|
|
424
|
+
const viewport = event.currentTarget;
|
|
425
|
+
this.#frames.schedule(() => this.#syncPosition(viewport, this.#overflowing));
|
|
376
426
|
};
|
|
377
427
|
#onFontsSettled = () => {
|
|
378
428
|
this.#refresh();
|
|
@@ -387,7 +437,7 @@ var ScrollAreaController = class extends Controller {
|
|
|
387
437
|
disconnect() {
|
|
388
438
|
this.#beforeCache.deactivate();
|
|
389
439
|
this.#rebind.cancel();
|
|
390
|
-
this.#
|
|
440
|
+
this.#frames.cancel();
|
|
391
441
|
if (this.#viewport) this.#unbindViewport(this.#viewport);
|
|
392
442
|
this.#layout.disconnect();
|
|
393
443
|
this.#unbindFonts();
|
|
@@ -420,7 +470,7 @@ var ScrollAreaController = class extends Controller {
|
|
|
420
470
|
return;
|
|
421
471
|
}
|
|
422
472
|
next.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
423
|
-
|
|
473
|
+
this.#layout.observeDescendantLoads(next);
|
|
424
474
|
this.#layout.observe(next);
|
|
425
475
|
this.#bindContentObserver(next);
|
|
426
476
|
this.#refresh();
|
|
@@ -455,9 +505,9 @@ var ScrollAreaController = class extends Controller {
|
|
|
455
505
|
}
|
|
456
506
|
/** Releases every resource and borrowed attribute owned by one former viewport. */
|
|
457
507
|
#unbindViewport(viewport) {
|
|
458
|
-
this.#
|
|
508
|
+
this.#frames.cancel();
|
|
459
509
|
viewport.removeEventListener("scroll", this.#onScroll);
|
|
460
|
-
|
|
510
|
+
this.#layout.unobserveDescendantLoads();
|
|
461
511
|
this.#layout.unobserve(viewport);
|
|
462
512
|
this.#content?.disconnect();
|
|
463
513
|
this.#content = null;
|
|
@@ -472,7 +522,7 @@ var ScrollAreaController = class extends Controller {
|
|
|
472
522
|
#refresh() {
|
|
473
523
|
const viewport = this.#viewport;
|
|
474
524
|
if (!viewport) return;
|
|
475
|
-
this.#
|
|
525
|
+
this.#frames.cancel();
|
|
476
526
|
this.#syncNameSources(viewport);
|
|
477
527
|
this.#overflowing = this.#syncOverflow(viewport);
|
|
478
528
|
this.#syncKeyboardReach(viewport, this.#overflowing);
|
|
@@ -680,16 +730,10 @@ var ScrollAreaController = class extends Controller {
|
|
|
680
730
|
this.#fonts?.removeEventListener("loadingerror", this.#onFontsSettled);
|
|
681
731
|
this.#fonts = null;
|
|
682
732
|
}
|
|
683
|
-
/** Cancels a pending scroll frame. */
|
|
684
|
-
#cancelScrollFrame() {
|
|
685
|
-
if (this.#scrollFrame === null) return;
|
|
686
|
-
cancelAnimationFrame(this.#scrollFrame);
|
|
687
|
-
this.#scrollFrame = null;
|
|
688
|
-
}
|
|
689
733
|
/** Suspends live resources and returns all derived state before snapshotting. */
|
|
690
734
|
#rewindForCache() {
|
|
691
735
|
this.#rebind.cancel();
|
|
692
|
-
this.#
|
|
736
|
+
this.#frames.cancel();
|
|
693
737
|
if (this.#viewport) this.#unbindViewport(this.#viewport);
|
|
694
738
|
this.#layout.disconnect();
|
|
695
739
|
this.#unbindFonts();
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/scroll_restore_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/frame_coalescer.ts
|
|
6
|
+
var FrameCoalescer = class {
|
|
7
|
+
#frame = null;
|
|
8
|
+
/**
|
|
9
|
+
* Runs `run` on the next frame, unless a frame is already pending — the first
|
|
10
|
+
* request of a burst wins and the rest are dropped. The pending frame is
|
|
11
|
+
* released before `run`, so `run` may request the next one.
|
|
12
|
+
*/
|
|
13
|
+
schedule(run) {
|
|
14
|
+
if (this.#frame !== null) return;
|
|
15
|
+
this.#frame = requestAnimationFrame(() => {
|
|
16
|
+
this.#frame = null;
|
|
17
|
+
run();
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Drops the pending frame, and reaches the platform only when there is one.
|
|
22
|
+
*
|
|
23
|
+
* There is no handle value that stands for "nothing pending":
|
|
24
|
+
* `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
|
|
25
|
+
* arrives as a large positive number that the same allocator can hand out, and
|
|
26
|
+
* an idle cancel would then drop a frame belonging to someone else.
|
|
27
|
+
*/
|
|
28
|
+
cancel() {
|
|
29
|
+
if (this.#frame === null) return;
|
|
30
|
+
cancelAnimationFrame(this.#frame);
|
|
31
|
+
this.#frame = null;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
3
35
|
// src/controllers/scroll_restore_controller.ts
|
|
4
36
|
function parseStored(raw) {
|
|
5
37
|
if (raw === null) return null;
|
|
@@ -17,8 +49,8 @@ var ScrollRestoreController = class extends Controller {
|
|
|
17
49
|
key: { type: String, default: "" },
|
|
18
50
|
axis: { type: String, default: "vertical" }
|
|
19
51
|
};
|
|
20
|
-
/**
|
|
21
|
-
#
|
|
52
|
+
/** Coalesces scroll bursts into one save per frame. */
|
|
53
|
+
#frames = new FrameCoalescer();
|
|
22
54
|
/** Resolved storage key; empty disables persistence (no key and no id). */
|
|
23
55
|
#storageKey = "";
|
|
24
56
|
/** Last offset captured while the element was live; persisted as-is on teardown. */
|
|
@@ -46,11 +78,7 @@ var ScrollRestoreController = class extends Controller {
|
|
|
46
78
|
#connected = false;
|
|
47
79
|
#onScroll = () => {
|
|
48
80
|
this.#capture();
|
|
49
|
-
|
|
50
|
-
this.#rafId = requestAnimationFrame(() => {
|
|
51
|
-
this.#rafId = null;
|
|
52
|
-
this.#persist();
|
|
53
|
-
});
|
|
81
|
+
this.#frames.schedule(() => this.#persist());
|
|
54
82
|
};
|
|
55
83
|
connect() {
|
|
56
84
|
this.#connected = true;
|
|
@@ -61,7 +89,7 @@ var ScrollRestoreController = class extends Controller {
|
|
|
61
89
|
disconnect() {
|
|
62
90
|
this.#connected = false;
|
|
63
91
|
this.element.removeEventListener("scroll", this.#onScroll);
|
|
64
|
-
this.#
|
|
92
|
+
this.#frames.cancel();
|
|
65
93
|
this.#persist();
|
|
66
94
|
}
|
|
67
95
|
/** Re-derives the namespace when application code or a Turbo morph moves `key`. */
|
|
@@ -75,7 +103,7 @@ var ScrollRestoreController = class extends Controller {
|
|
|
75
103
|
/** Rebuilds the persistence state around the Values as they now read. */
|
|
76
104
|
#resync() {
|
|
77
105
|
if (!this.#connected) return;
|
|
78
|
-
this.#
|
|
106
|
+
this.#frames.cancel();
|
|
79
107
|
this.#storageKey = this.#resolveKey();
|
|
80
108
|
this.#echoTop = null;
|
|
81
109
|
this.#echoLeft = null;
|
|
@@ -84,13 +112,6 @@ var ScrollRestoreController = class extends Controller {
|
|
|
84
112
|
this.#capturedLeft = false;
|
|
85
113
|
if (this.#storageKey) this.#restore();
|
|
86
114
|
}
|
|
87
|
-
/** Drops the pending coalesced save, if one is queued. */
|
|
88
|
-
#cancelFrame() {
|
|
89
|
-
if (this.#rafId !== null) {
|
|
90
|
-
cancelAnimationFrame(this.#rafId);
|
|
91
|
-
this.#rafId = null;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
115
|
/**
|
|
95
116
|
* Records the live scroll offset for the configured axis.
|
|
96
117
|
*
|