stimeo-ui 0.14.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 +34 -0
- 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 +20 -3
- data/dist/controllers/checkbox_controller.js +27 -2
- data/dist/controllers/conditional_fields_controller.js +29 -6
- data/dist/controllers/currency_input_controller.js +16 -9
- data/dist/controllers/date_range_picker_controller.js +12 -3
- data/dist/controllers/dirty_form_controller.js +32 -12
- data/dist/controllers/flash_controller.js +145 -43
- data/dist/controllers/frame_loading_controller.js +29 -6
- data/dist/controllers/highlight_controller.js +63 -23
- data/dist/controllers/local_time_controller.js +10 -5
- data/dist/controllers/masonry_controller.js +31 -13
- data/dist/controllers/optimistic_controller.js +28 -4
- data/dist/controllers/overflow_indicator_controller.js +31 -8
- data/dist/controllers/overflow_menu_controller.js +29 -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 +10 -5
- 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 +49 -29
- data/dist/controllers/scrollspy_controller.js +65 -26
- data/dist/controllers/smart_sticky_header_controller.js +49 -25
- data/dist/controllers/stick_to_bottom_controller.js +29 -3
- data/dist/controllers/submit_once_controller.js +29 -6
- data/dist/controllers/textarea_autosize_controller.js +29 -3
- data/dist/controllers/toast_controller.js +136 -73
- data/dist/index.js +473 -435
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,11 +2,6 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/highlight_controller.ts
|
|
4
4
|
|
|
5
|
-
// src/utils/reduced_motion.ts
|
|
6
|
-
function prefersReducedMotion() {
|
|
7
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
5
|
// src/utils/safe_timeout.ts
|
|
11
6
|
var TimerRegistry = class {
|
|
12
7
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -60,6 +55,54 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
60
55
|
}
|
|
61
56
|
};
|
|
62
57
|
|
|
58
|
+
// src/utils/keyed_timers.ts
|
|
59
|
+
var KeyedTimers = class {
|
|
60
|
+
#timers = new SafeTimeout();
|
|
61
|
+
/** The pending timer of each key; an entry lives exactly as long as its timer. */
|
|
62
|
+
#pending = /* @__PURE__ */ new Map();
|
|
63
|
+
/**
|
|
64
|
+
* Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
|
|
65
|
+
* pending. The entry is dropped before the callback runs, so the callback sees
|
|
66
|
+
* the key unarmed and may arm it again for the next round.
|
|
67
|
+
*/
|
|
68
|
+
set(key, callback, delay) {
|
|
69
|
+
this.clear(key);
|
|
70
|
+
const id = this.#timers.set(() => {
|
|
71
|
+
this.#pending.delete(key);
|
|
72
|
+
callback();
|
|
73
|
+
}, delay);
|
|
74
|
+
this.#pending.set(key, id);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Cancels `key`'s pending timer, if it has one.
|
|
78
|
+
*
|
|
79
|
+
* A timer id is a positive integer, so `-1` stands for "nothing pending" and
|
|
80
|
+
* the registry ignores an id it does not own — the unarmed case needs no
|
|
81
|
+
* branch of its own, and no other key's timer can be reached from here.
|
|
82
|
+
*/
|
|
83
|
+
clear(key) {
|
|
84
|
+
this.#timers.clear(this.#pending.get(key) ?? -1);
|
|
85
|
+
this.#pending.delete(key);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Cancels every pending timer and forgets every key. Call this from a
|
|
89
|
+
* controller's `disconnect()` so no timer, and no entry, outlives the element.
|
|
90
|
+
*/
|
|
91
|
+
clearAll() {
|
|
92
|
+
this.#timers.clearAll();
|
|
93
|
+
this.#pending.clear();
|
|
94
|
+
}
|
|
95
|
+
/** Whether `key` has a timer pending. */
|
|
96
|
+
has(key) {
|
|
97
|
+
return this.#pending.has(key);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// src/utils/reduced_motion.ts
|
|
102
|
+
function prefersReducedMotion() {
|
|
103
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
104
|
+
}
|
|
105
|
+
|
|
63
106
|
// src/controllers/highlight_controller.ts
|
|
64
107
|
var hookOwners = /* @__PURE__ */ new WeakMap();
|
|
65
108
|
var HighlightController = class extends Controller {
|
|
@@ -68,14 +111,11 @@ var HighlightController = class extends Controller {
|
|
|
68
111
|
observe: { type: Boolean, default: false }
|
|
69
112
|
};
|
|
70
113
|
static events = ["start", "end"];
|
|
71
|
-
#timeouts = new SafeTimeout();
|
|
72
114
|
/**
|
|
73
|
-
* The removal timer this connection has outstanding for
|
|
74
|
-
*
|
|
75
|
-
* so a cleared id can never be matched against a recycled one. Which connection owns
|
|
76
|
-
* an element's hook is answered by the shared owner registry above.
|
|
115
|
+
* The removal timer this connection has outstanding for each element. Which
|
|
116
|
+
* connection owns an element's hook is answered by the shared owner registry above.
|
|
77
117
|
*/
|
|
78
|
-
#
|
|
118
|
+
#removal = new KeyedTimers();
|
|
79
119
|
#observer = null;
|
|
80
120
|
connect() {
|
|
81
121
|
this.#clearArrivedHook(this.element);
|
|
@@ -92,8 +132,7 @@ var HighlightController = class extends Controller {
|
|
|
92
132
|
disconnect() {
|
|
93
133
|
this.#observer?.disconnect();
|
|
94
134
|
this.#observer = null;
|
|
95
|
-
this.#
|
|
96
|
-
this.#pending = /* @__PURE__ */ new WeakMap();
|
|
135
|
+
this.#removal.clearAll();
|
|
97
136
|
}
|
|
98
137
|
/** Drops a hook that arrived with the DOM, along with this connection's claim on it. */
|
|
99
138
|
#clearArrivedHook(el) {
|
|
@@ -114,13 +153,15 @@ var HighlightController = class extends Controller {
|
|
|
114
153
|
this.#releasePending(el);
|
|
115
154
|
el.setAttribute("data-highlight", "true");
|
|
116
155
|
this.dispatch("start", { target: el, detail: { element: el } });
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
156
|
+
this.#removal.set(
|
|
157
|
+
el,
|
|
158
|
+
() => {
|
|
159
|
+
hookOwners.delete(el);
|
|
160
|
+
el.removeAttribute("data-highlight");
|
|
161
|
+
this.dispatch("end", { target: el, detail: { element: el } });
|
|
162
|
+
},
|
|
163
|
+
this.durationValue
|
|
164
|
+
);
|
|
124
165
|
hookOwners.set(el, this);
|
|
125
166
|
}
|
|
126
167
|
/**
|
|
@@ -133,10 +174,9 @@ var HighlightController = class extends Controller {
|
|
|
133
174
|
if (owner !== void 0 && owner !== this) owner.#cancelPending(el);
|
|
134
175
|
this.#cancelPending(el);
|
|
135
176
|
}
|
|
136
|
-
/** Releases `el`'s pending removal timer, if it has one. */
|
|
177
|
+
/** Releases `el`'s pending removal timer, if it has one, and this connection's claim. */
|
|
137
178
|
#cancelPending(el) {
|
|
138
|
-
this.#
|
|
139
|
-
this.#pending.delete(el);
|
|
179
|
+
this.#removal.clear(el);
|
|
140
180
|
hookOwners.delete(el);
|
|
141
181
|
}
|
|
142
182
|
};
|
|
@@ -31,6 +31,11 @@ function construct(factory, locale, options, fallbackLocale) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// src/utils/locale.ts
|
|
35
|
+
function resolveLocale(element, declared = "") {
|
|
36
|
+
return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
// src/utils/microtask_coalescer.ts
|
|
35
40
|
var MicrotaskCoalescer = class {
|
|
36
41
|
#run;
|
|
@@ -190,13 +195,13 @@ var LocalTimeController = class extends Controller {
|
|
|
190
195
|
};
|
|
191
196
|
if (options.dateStyle === void 0 && options.timeStyle === void 0) return null;
|
|
192
197
|
if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;
|
|
193
|
-
const formatter = intlFormatter(
|
|
198
|
+
const formatter = intlFormatter(
|
|
199
|
+
Intl.DateTimeFormat,
|
|
200
|
+
resolveLocale(this.element, this.localeValue),
|
|
201
|
+
options
|
|
202
|
+
);
|
|
194
203
|
return formatter === null ? null : formatter.format(date);
|
|
195
204
|
}
|
|
196
|
-
/** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
|
|
197
|
-
get #locale() {
|
|
198
|
-
return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
199
|
-
}
|
|
200
205
|
};
|
|
201
206
|
|
|
202
207
|
export { LocalTimeController };
|
|
@@ -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
|
|
|
@@ -127,13 +153,6 @@ var MasonryController = class extends Controller {
|
|
|
127
153
|
#mutationObserver = null;
|
|
128
154
|
/** Last published column count, so `layout` fires only on real changes. */
|
|
129
155
|
#lastColumns = 0;
|
|
130
|
-
/**
|
|
131
|
-
* Re-pack when a descendant resource finishes loading. Images/iframes report a
|
|
132
|
-
* height of 0 until loaded, which would skew the shortest-column packing if the
|
|
133
|
-
* first pass ran before they settled; `load` does not bubble, so this is bound in
|
|
134
|
-
* the capture phase to catch every descendant.
|
|
135
|
-
*/
|
|
136
|
-
#onLoad = () => this.#reconcile.schedule();
|
|
137
156
|
/** Resolves the declared column width once, falling back when it is unreadable. */
|
|
138
157
|
minColumnWidthValueChanged() {
|
|
139
158
|
this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);
|
|
@@ -168,18 +187,17 @@ var MasonryController = class extends Controller {
|
|
|
168
187
|
this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());
|
|
169
188
|
this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
|
|
170
189
|
}
|
|
171
|
-
this.
|
|
190
|
+
this.#layout.observeDescendantLoads(this.element);
|
|
172
191
|
this.#relayout();
|
|
173
192
|
this.#reconcile.activate();
|
|
174
193
|
}
|
|
175
|
-
/** Releases
|
|
194
|
+
/** Releases every observation so nothing fires after detach. */
|
|
176
195
|
disconnect() {
|
|
177
196
|
this.#reconcile.cancel();
|
|
178
197
|
this.#released.clear();
|
|
179
198
|
this.#layout.disconnect();
|
|
180
199
|
this.#mutationObserver?.disconnect();
|
|
181
200
|
this.#mutationObserver = null;
|
|
182
|
-
this.element.removeEventListener("load", this.#onLoad, true);
|
|
183
201
|
this.#lastColumns = 0;
|
|
184
202
|
}
|
|
185
203
|
/**
|
|
@@ -57,6 +57,30 @@ var DetachGate = class _DetachGate {
|
|
|
57
57
|
}
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
// src/utils/listener_set.ts
|
|
61
|
+
var ListenerSet = class {
|
|
62
|
+
/** The generation every `add` joins until the next `dispose()`. */
|
|
63
|
+
#abort = new AbortController();
|
|
64
|
+
/**
|
|
65
|
+
* Attaches `handler` to the open generation, exactly as the caller spelled it.
|
|
66
|
+
*
|
|
67
|
+
* The set supplies the signal, so `options` carries everything else the DOM
|
|
68
|
+
* accepts — `capture` included, which has to match at release time and no
|
|
69
|
+
* longer has a second place to drift from.
|
|
70
|
+
*/
|
|
71
|
+
add(target, type, handler, options) {
|
|
72
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Releases every listener of the open generation, synchronously, and opens the
|
|
76
|
+
* next one. Idempotent, and safe before anything has been added.
|
|
77
|
+
*/
|
|
78
|
+
dispose() {
|
|
79
|
+
this.#abort.abort();
|
|
80
|
+
this.#abort = new AbortController();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
60
84
|
// src/controllers/optimistic_controller.ts
|
|
61
85
|
var HIDDEN_MARKER = "data-optimistic-toggled";
|
|
62
86
|
var BUSY_MARKER = "data-optimistic-busy";
|
|
@@ -73,6 +97,7 @@ var OptimisticController = class extends Controller {
|
|
|
73
97
|
/** The submission that owns the applied optimistic state, if any. */
|
|
74
98
|
#pending = null;
|
|
75
99
|
#gate = new DetachGate();
|
|
100
|
+
#listeners = new ListenerSet();
|
|
76
101
|
/** True between `connect()` and `disconnect()`, so a repeat connect rewinds nothing. */
|
|
77
102
|
#connected = false;
|
|
78
103
|
connect() {
|
|
@@ -80,13 +105,12 @@ var OptimisticController = class extends Controller {
|
|
|
80
105
|
this.#gate.cancel();
|
|
81
106
|
this.#connected = true;
|
|
82
107
|
if (restored && this.element.hasAttribute("data-optimistic")) this.#revert();
|
|
83
|
-
this.element
|
|
84
|
-
this.element
|
|
108
|
+
this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
|
|
109
|
+
this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
|
|
85
110
|
}
|
|
86
111
|
disconnect() {
|
|
87
112
|
this.#connected = false;
|
|
88
|
-
this.
|
|
89
|
-
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
113
|
+
this.#listeners.dispose();
|
|
90
114
|
this.#gate.disconnected(this, () => {
|
|
91
115
|
this.#pending = null;
|
|
92
116
|
});
|
|
@@ -63,10 +63,15 @@ var LayoutObserver = class {
|
|
|
63
63
|
#resizeObserverFactory;
|
|
64
64
|
#resizeObserver = null;
|
|
65
65
|
#observingViewport = false;
|
|
66
|
+
#loadContainer = null;
|
|
66
67
|
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
67
68
|
#handleViewportResize = () => {
|
|
68
69
|
this.#callback();
|
|
69
70
|
};
|
|
71
|
+
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
72
|
+
#handleDescendantLoad = () => {
|
|
73
|
+
this.#callback();
|
|
74
|
+
};
|
|
70
75
|
constructor(callback, options = {}) {
|
|
71
76
|
this.#callback = callback;
|
|
72
77
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
@@ -102,14 +107,35 @@ var LayoutObserver = class {
|
|
|
102
107
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
103
108
|
}
|
|
104
109
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
110
|
+
* Starts reporting a `load` from anywhere inside `container` — an image or a
|
|
111
|
+
* frame settling changes the box it sits in, and it measures as zero high until
|
|
112
|
+
* then. `load` does not bubble, so the subscription is a capture-phase listener
|
|
113
|
+
* on the container itself and nothing the caller spells.
|
|
114
|
+
*
|
|
115
|
+
* **One container at a time.** A further call moves the observation, so a widget
|
|
116
|
+
* whose content element is swapped at runtime releases the element it let go by
|
|
117
|
+
* naming the new one — there is no second place for the release to drift from.
|
|
118
|
+
*/
|
|
119
|
+
observeDescendantLoads(container) {
|
|
120
|
+
this.unobserveDescendantLoads();
|
|
121
|
+
this.#loadContainer = container;
|
|
122
|
+
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
123
|
+
}
|
|
124
|
+
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
125
|
+
unobserveDescendantLoads() {
|
|
126
|
+
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
127
|
+
this.#loadContainer = null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and removes
|
|
131
|
+
* the viewport and descendant-load listeners. Safe to call multiple times. Call
|
|
132
|
+
* this from a controller's `disconnect()`.
|
|
108
133
|
*/
|
|
109
134
|
disconnect() {
|
|
110
135
|
this.#resizeObserver?.disconnect();
|
|
111
136
|
this.#resizeObserver = null;
|
|
112
137
|
this.unobserveViewport();
|
|
138
|
+
this.unobserveDescendantLoads();
|
|
113
139
|
}
|
|
114
140
|
};
|
|
115
141
|
|
|
@@ -324,7 +350,7 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
324
350
|
this.#observedViewport = next;
|
|
325
351
|
this.#layout.observe(next);
|
|
326
352
|
this.#layout.observeViewport();
|
|
327
|
-
|
|
353
|
+
this.#layout.observeDescendantLoads(next);
|
|
328
354
|
this.#syncContentObservation();
|
|
329
355
|
if (typeof MutationObserver !== "undefined") {
|
|
330
356
|
this.#mutationObserver = new MutationObserver(() => {
|
|
@@ -362,16 +388,13 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
362
388
|
#stopObservingViewport() {
|
|
363
389
|
this.#mutationObserver?.disconnect();
|
|
364
390
|
this.#mutationObserver = null;
|
|
365
|
-
this.#
|
|
391
|
+
this.#layout.unobserveDescendantLoads();
|
|
366
392
|
if (this.#observedViewport) this.#layout.unobserve(this.#observedViewport);
|
|
367
393
|
for (const content of this.#observedContent) this.#layout.unobserve(content);
|
|
368
394
|
this.#observedContent.clear();
|
|
369
395
|
this.#observedViewport = null;
|
|
370
396
|
this.#layout.unobserveViewport();
|
|
371
397
|
}
|
|
372
|
-
#onContentLoad = () => {
|
|
373
|
-
if (this.#connected) this.update();
|
|
374
|
-
};
|
|
375
398
|
get #threshold() {
|
|
376
399
|
const value = this.thresholdValue;
|
|
377
400
|
return Number.isFinite(value) ? Math.max(0, value) : 1;
|
|
@@ -64,10 +64,15 @@ var LayoutObserver = class {
|
|
|
64
64
|
#resizeObserverFactory;
|
|
65
65
|
#resizeObserver = null;
|
|
66
66
|
#observingViewport = false;
|
|
67
|
+
#loadContainer = null;
|
|
67
68
|
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
68
69
|
#handleViewportResize = () => {
|
|
69
70
|
this.#callback();
|
|
70
71
|
};
|
|
72
|
+
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
73
|
+
#handleDescendantLoad = () => {
|
|
74
|
+
this.#callback();
|
|
75
|
+
};
|
|
71
76
|
constructor(callback, options = {}) {
|
|
72
77
|
this.#callback = callback;
|
|
73
78
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
@@ -103,14 +108,35 @@ var LayoutObserver = class {
|
|
|
103
108
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
104
109
|
}
|
|
105
110
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
111
|
+
* Starts reporting a `load` from anywhere inside `container` — an image or a
|
|
112
|
+
* frame settling changes the box it sits in, and it measures as zero high until
|
|
113
|
+
* then. `load` does not bubble, so the subscription is a capture-phase listener
|
|
114
|
+
* on the container itself and nothing the caller spells.
|
|
115
|
+
*
|
|
116
|
+
* **One container at a time.** A further call moves the observation, so a widget
|
|
117
|
+
* whose content element is swapped at runtime releases the element it let go by
|
|
118
|
+
* naming the new one — there is no second place for the release to drift from.
|
|
119
|
+
*/
|
|
120
|
+
observeDescendantLoads(container) {
|
|
121
|
+
this.unobserveDescendantLoads();
|
|
122
|
+
this.#loadContainer = container;
|
|
123
|
+
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
124
|
+
}
|
|
125
|
+
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
126
|
+
unobserveDescendantLoads() {
|
|
127
|
+
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
128
|
+
this.#loadContainer = null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and removes
|
|
132
|
+
* the viewport and descendant-load listeners. Safe to call multiple times. Call
|
|
133
|
+
* this from a controller's `disconnect()`.
|
|
109
134
|
*/
|
|
110
135
|
disconnect() {
|
|
111
136
|
this.#resizeObserver?.disconnect();
|
|
112
137
|
this.#resizeObserver = null;
|
|
113
138
|
this.unobserveViewport();
|
|
139
|
+
this.unobserveDescendantLoads();
|
|
114
140
|
}
|
|
115
141
|
};
|
|
116
142
|
|
|
@@ -63,10 +63,15 @@ var LayoutObserver = class {
|
|
|
63
63
|
#resizeObserverFactory;
|
|
64
64
|
#resizeObserver = null;
|
|
65
65
|
#observingViewport = false;
|
|
66
|
+
#loadContainer = null;
|
|
66
67
|
/** Stable bound handler so add/removeEventListener target the same reference. */
|
|
67
68
|
#handleViewportResize = () => {
|
|
68
69
|
this.#callback();
|
|
69
70
|
};
|
|
71
|
+
/** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
|
|
72
|
+
#handleDescendantLoad = () => {
|
|
73
|
+
this.#callback();
|
|
74
|
+
};
|
|
70
75
|
constructor(callback, options = {}) {
|
|
71
76
|
this.#callback = callback;
|
|
72
77
|
this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
|
|
@@ -102,14 +107,35 @@ var LayoutObserver = class {
|
|
|
102
107
|
window.removeEventListener("resize", this.#handleViewportResize);
|
|
103
108
|
}
|
|
104
109
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
110
|
+
* Starts reporting a `load` from anywhere inside `container` — an image or a
|
|
111
|
+
* frame settling changes the box it sits in, and it measures as zero high until
|
|
112
|
+
* then. `load` does not bubble, so the subscription is a capture-phase listener
|
|
113
|
+
* on the container itself and nothing the caller spells.
|
|
114
|
+
*
|
|
115
|
+
* **One container at a time.** A further call moves the observation, so a widget
|
|
116
|
+
* whose content element is swapped at runtime releases the element it let go by
|
|
117
|
+
* naming the new one — there is no second place for the release to drift from.
|
|
118
|
+
*/
|
|
119
|
+
observeDescendantLoads(container) {
|
|
120
|
+
this.unobserveDescendantLoads();
|
|
121
|
+
this.#loadContainer = container;
|
|
122
|
+
container.addEventListener("load", this.#handleDescendantLoad, true);
|
|
123
|
+
}
|
|
124
|
+
/** Stops reporting descendant loads without affecting element or viewport observation. */
|
|
125
|
+
unobserveDescendantLoads() {
|
|
126
|
+
this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
|
|
127
|
+
this.#loadContainer = null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Releases every observation: disconnects the {@link ResizeObserver} and removes
|
|
131
|
+
* the viewport and descendant-load listeners. Safe to call multiple times. Call
|
|
132
|
+
* this from a controller's `disconnect()`.
|
|
108
133
|
*/
|
|
109
134
|
disconnect() {
|
|
110
135
|
this.#resizeObserver?.disconnect();
|
|
111
136
|
this.#resizeObserver = null;
|
|
112
137
|
this.unobserveViewport();
|
|
138
|
+
this.unobserveDescendantLoads();
|
|
113
139
|
}
|
|
114
140
|
};
|
|
115
141
|
|
|
@@ -192,7 +218,7 @@ var ReadMoreController = class extends Controller {
|
|
|
192
218
|
this.#observedContent = next;
|
|
193
219
|
this.#layout.observe(next);
|
|
194
220
|
this.#layout.observeViewport();
|
|
195
|
-
|
|
221
|
+
this.#layout.observeDescendantLoads(next);
|
|
196
222
|
if (typeof MutationObserver !== "undefined") {
|
|
197
223
|
this.#contentMutationObserver = new MutationObserver(this.#update);
|
|
198
224
|
this.#contentMutationObserver.observe(next, {
|
|
@@ -206,7 +232,7 @@ var ReadMoreController = class extends Controller {
|
|
|
206
232
|
this.#deferredHide.releaseAll();
|
|
207
233
|
if (this.#observedContent) {
|
|
208
234
|
this.#layout.unobserve(this.#observedContent);
|
|
209
|
-
this.#
|
|
235
|
+
this.#layout.unobserveDescendantLoads();
|
|
210
236
|
}
|
|
211
237
|
this.#observedContent = null;
|
|
212
238
|
this.#contentMutationObserver?.disconnect();
|
|
@@ -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();
|