stimeo-ui 0.2.1 → 0.4.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 +160 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +47 -6
- data/dist/controllers/collapsible_controller.js +2 -2
- data/dist/controllers/color_picker_controller.js +92 -7
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +226 -22
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +77 -3
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/intersection_controller.js +36 -11
- data/dist/controllers/lazy_frame_controller.js +31 -10
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +102 -8
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +81 -13
- data/dist/controllers/overflow_menu_controller.js +408 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/progress_controller.js +116 -9
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +100 -9
- data/dist/controllers/rating_controller.js +69 -2
- data/dist/controllers/read_more_controller.js +63 -19
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +86 -12
- data/dist/controllers/scroll_restore_controller.js +1 -1
- data/dist/controllers/scroll_visibility_controller.js +33 -3
- data/dist/controllers/scrollspy_controller.js +346 -73
- data/dist/controllers/separator_controller.js +9 -0
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +49 -8
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +228 -27
- data/dist/controllers/step_indicator_controller.js +82 -5
- data/dist/controllers/stick_to_bottom_controller.js +61 -10
- data/dist/controllers/sticky_observer_controller.js +32 -11
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +22 -2
- data/dist/controllers/theme_controller.js +22 -3
- data/dist/controllers/time_picker_controller.js +20 -1
- data/dist/controllers/toast_controller.js +4 -5
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/tree_view_controller.js +467 -51
- data/dist/index.js +4507 -907
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +2 -2
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/focus_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
// src/utils/escape_layer.ts
|
|
6
35
|
var EscapeLayer = class _EscapeLayer {
|
|
7
36
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -135,7 +164,7 @@ var FocusTrap = class {
|
|
|
135
164
|
}
|
|
136
165
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
166
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
-
|
|
167
|
+
this.#beforeCache.activate();
|
|
139
168
|
const onEscape = this.#options.onEscape;
|
|
140
169
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
170
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -152,7 +181,7 @@ var FocusTrap = class {
|
|
|
152
181
|
this.#activeState = false;
|
|
153
182
|
this.#escapeLayer.deactivate();
|
|
154
183
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
-
|
|
184
|
+
this.#beforeCache.deactivate();
|
|
156
185
|
if (this.#scrollLocked) {
|
|
157
186
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
187
|
this.#scrollLocked = false;
|
|
@@ -176,9 +205,7 @@ var FocusTrap = class {
|
|
|
176
205
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
206
|
* is left alone mid-navigation. The listener lives only while active.
|
|
178
207
|
*/
|
|
179
|
-
#
|
|
180
|
-
this.deactivate({ restoreFocus: false });
|
|
181
|
-
};
|
|
208
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
182
209
|
/**
|
|
183
210
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
211
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -216,7 +216,7 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
216
216
|
}
|
|
217
217
|
/**
|
|
218
218
|
* Where focus should land for an invalid control. A visible control is focused
|
|
219
|
-
* directly
|
|
219
|
+
* directly — the case for native fields and radios. A validatable mirror
|
|
220
220
|
* (the `hidden` attribute) cannot receive focus, so focus is delegated to the
|
|
221
221
|
* visible widget: the owning field's `control` target when it is itself
|
|
222
222
|
* focusable, else its first focusable descendant (e.g. a roving-tabindex
|
|
@@ -2,6 +2,145 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/frame_loading_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/before_cache_reset.ts
|
|
23
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
24
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
25
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
26
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
27
|
+
static #onBeforeCache = () => {
|
|
28
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
29
|
+
};
|
|
30
|
+
#rewind;
|
|
31
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
32
|
+
constructor(rewind) {
|
|
33
|
+
this.#rewind = rewind;
|
|
34
|
+
}
|
|
35
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
36
|
+
activate() {
|
|
37
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
38
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
39
|
+
if (first) {
|
|
40
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
44
|
+
deactivate() {
|
|
45
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
46
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
47
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// src/utils/detach_gate.ts
|
|
52
|
+
var DetachGate = class _DetachGate {
|
|
53
|
+
/** Set while a probe is queued, waiting for a reconnect to cancel it. */
|
|
54
|
+
#pending = false;
|
|
55
|
+
/**
|
|
56
|
+
* True when the disconnect is definitely a real detach — the element left
|
|
57
|
+
* the document, or `data-controller` no longer lists the identifier. False
|
|
58
|
+
* means ambiguous (in-page move or observed-root exit), NOT "alive".
|
|
59
|
+
*/
|
|
60
|
+
static isDetached(host) {
|
|
61
|
+
if (!host.element.isConnected) return true;
|
|
62
|
+
const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
|
|
63
|
+
return !tokens.includes(host.identifier);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Call from `disconnect()`: runs `teardown` synchronously on a definite
|
|
67
|
+
* detach (fast path), otherwise defers it one microtask — a reconnect
|
|
68
|
+
* ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
|
|
69
|
+
* One microtask is the whole probe window: Stimulus reconnects a moved
|
|
70
|
+
* element within the same mutation batch, before the checkpoint drains.
|
|
71
|
+
*/
|
|
72
|
+
disconnected(host, teardown) {
|
|
73
|
+
if (_DetachGate.isDetached(host)) {
|
|
74
|
+
this.#pending = false;
|
|
75
|
+
teardown();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.#pending = true;
|
|
79
|
+
queueMicrotask(() => {
|
|
80
|
+
if (!this.#pending) return;
|
|
81
|
+
this.#pending = false;
|
|
82
|
+
teardown();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Disarms a pending probe. Call from `connect()` (the reconnect that proves
|
|
87
|
+
* an in-page move) and from the head of any teardown path not routed through
|
|
88
|
+
* {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
|
|
89
|
+
* never run the teardown a second time.
|
|
90
|
+
*/
|
|
91
|
+
cancel() {
|
|
92
|
+
this.#pending = false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// src/utils/min_duration_floor.ts
|
|
97
|
+
var MinDurationFloor = class {
|
|
98
|
+
#timers;
|
|
99
|
+
/** Pending finish timer id, or `null` when nothing is held back. */
|
|
100
|
+
#timerId = null;
|
|
101
|
+
/** Epoch ms the floor is measured from. */
|
|
102
|
+
#since = 0;
|
|
103
|
+
/** @param timers - the controller's registry; the floor schedules into it. */
|
|
104
|
+
constructor(timers) {
|
|
105
|
+
this.#timers = timers;
|
|
106
|
+
}
|
|
107
|
+
/** Starts the floor: call when the state being held becomes visible. */
|
|
108
|
+
begin() {
|
|
109
|
+
this.#since = Date.now();
|
|
110
|
+
}
|
|
111
|
+
/** True while a finish is held back waiting for the floor to elapse. */
|
|
112
|
+
get pending() {
|
|
113
|
+
return this.#timerId !== null;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Runs `finish` once the floor has elapsed, immediately when it already has.
|
|
117
|
+
*
|
|
118
|
+
* A held-back finish is **replaced**, never stacked: only the most recently
|
|
119
|
+
* queued id is cancellable, so a second timer would outlive every cancel and
|
|
120
|
+
* end a state that has since restarted. Controllers that want the first signal
|
|
121
|
+
* to win guard on {@link pending} before calling.
|
|
122
|
+
*/
|
|
123
|
+
schedule(minDuration, finish) {
|
|
124
|
+
this.cancel();
|
|
125
|
+
const remaining = minDuration - (Date.now() - this.#since);
|
|
126
|
+
if (remaining > 0) {
|
|
127
|
+
this.#timerId = this.#timers.set(() => {
|
|
128
|
+
this.#timerId = null;
|
|
129
|
+
finish();
|
|
130
|
+
}, remaining);
|
|
131
|
+
} else {
|
|
132
|
+
finish();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Drops a held-back finish. Safe when none is queued, or after a bulk clear. */
|
|
136
|
+
cancel() {
|
|
137
|
+
if (this.#timerId !== null) {
|
|
138
|
+
this.#timers.clear(this.#timerId);
|
|
139
|
+
this.#timerId = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
5
144
|
// src/utils/safe_timeout.ts
|
|
6
145
|
var TimerRegistry = class {
|
|
7
146
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -59,32 +198,32 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
59
198
|
var FrameLoadingController = class extends Controller {
|
|
60
199
|
static targets = ["content", "skeleton", "overlay"];
|
|
61
200
|
static values = {
|
|
201
|
+
announceText: { type: String, default: "" },
|
|
202
|
+
announceReadyText: { type: String, default: "" },
|
|
62
203
|
minDuration: { type: Number, default: 0 },
|
|
63
204
|
restoreFocus: { type: Boolean, default: true }
|
|
64
205
|
};
|
|
65
206
|
static events = ["start", "end"];
|
|
66
207
|
#timeouts = new SafeTimeout();
|
|
208
|
+
#floor = new MinDurationFloor(this.#timeouts);
|
|
209
|
+
#gate = new DetachGate();
|
|
210
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
67
211
|
#loading = false;
|
|
68
|
-
#startedAt = 0;
|
|
69
212
|
#inertApplied = false;
|
|
70
213
|
#previousFocus = null;
|
|
71
214
|
/** The id of the retreated element, used to re-find it if the load replaced it. */
|
|
72
215
|
#previousFocusId = "";
|
|
73
216
|
#onStart = () => {
|
|
74
|
-
this.#
|
|
217
|
+
this.#floor.cancel();
|
|
75
218
|
if (!this.#loading) this.#begin();
|
|
76
219
|
};
|
|
77
220
|
#onEnd = () => {
|
|
78
221
|
if (!this.#loading) return;
|
|
79
|
-
|
|
80
|
-
if (remaining > 0) {
|
|
81
|
-
this.#timeouts.clearAll();
|
|
82
|
-
this.#timeouts.set(() => this.#finish(), remaining);
|
|
83
|
-
} else {
|
|
84
|
-
this.#finish();
|
|
85
|
-
}
|
|
222
|
+
this.#floor.schedule(this.minDurationValue, () => this.#finish());
|
|
86
223
|
};
|
|
87
224
|
connect() {
|
|
225
|
+
this.#gate.cancel();
|
|
226
|
+
this.#beforeCache.activate();
|
|
88
227
|
this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
|
|
89
228
|
this.element.addEventListener("turbo:frame-load", this.#onEnd);
|
|
90
229
|
this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
|
|
@@ -93,19 +232,40 @@ var FrameLoadingController = class extends Controller {
|
|
|
93
232
|
this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
|
|
94
233
|
this.element.removeEventListener("turbo:frame-load", this.#onEnd);
|
|
95
234
|
this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
|
|
235
|
+
this.#beforeCache.deactivate();
|
|
236
|
+
this.#gate.disconnected(this, () => this.#teardown());
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Drops the held finish and the loading bookkeeping on a real detach. The markup
|
|
240
|
+
* keeps whatever it last held: the page being cached is rewound at
|
|
241
|
+
* `turbo:before-cache` instead, where the frame is still whole.
|
|
242
|
+
*/
|
|
243
|
+
#teardown() {
|
|
244
|
+
this.#gate.cancel();
|
|
96
245
|
this.#timeouts.clearAll();
|
|
97
|
-
|
|
98
|
-
this.element.removeAttribute("aria-busy");
|
|
99
|
-
this.element.removeAttribute("data-frame-loading");
|
|
100
|
-
this.#clearInert();
|
|
101
|
-
}
|
|
246
|
+
this.#floor.cancel();
|
|
102
247
|
this.#loading = false;
|
|
103
248
|
this.#previousFocus = null;
|
|
104
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Returns the frame to its resting hooks for the snapshot Turbo is about to
|
|
252
|
+
* take, so a page reached with the Back button does not restore a frame that is
|
|
253
|
+
* busy and inert with nothing left to finish it. State only — no `end` event and
|
|
254
|
+
* no focus move, because the load did not actually complete. The live page keeps
|
|
255
|
+
* its held finish, so a navigation that never completes still ends properly.
|
|
256
|
+
*/
|
|
257
|
+
#rewindForCache() {
|
|
258
|
+
if (!this.#loading) return;
|
|
259
|
+
this.element.removeAttribute("aria-busy");
|
|
260
|
+
this.element.removeAttribute("data-frame-loading");
|
|
261
|
+
if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
|
|
262
|
+
if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
|
|
263
|
+
this.#clearInert();
|
|
264
|
+
}
|
|
105
265
|
/** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
|
|
106
266
|
#begin() {
|
|
107
267
|
this.#loading = true;
|
|
108
|
-
this.#
|
|
268
|
+
this.#floor.begin();
|
|
109
269
|
this.element.setAttribute("aria-busy", "true");
|
|
110
270
|
this.element.setAttribute("data-frame-loading", "true");
|
|
111
271
|
if (this.hasSkeletonTarget) this.skeletonTarget.hidden = false;
|
|
@@ -113,6 +273,7 @@ var FrameLoadingController = class extends Controller {
|
|
|
113
273
|
this.#applyInert();
|
|
114
274
|
this.#retreatFocus();
|
|
115
275
|
this.dispatch("start", { detail: {} });
|
|
276
|
+
announce(fillTemplate(this.announceTextValue, {}));
|
|
116
277
|
}
|
|
117
278
|
/** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
|
|
118
279
|
#finish() {
|
|
@@ -124,6 +285,7 @@ var FrameLoadingController = class extends Controller {
|
|
|
124
285
|
this.#clearInert();
|
|
125
286
|
this.#restoreFocus();
|
|
126
287
|
this.dispatch("end", { detail: {} });
|
|
288
|
+
announce(fillTemplate(this.announceReadyTextValue, {}));
|
|
127
289
|
}
|
|
128
290
|
/** Marks the content inert to block double-submits while stale (if we own it). */
|
|
129
291
|
#applyInert() {
|
|
@@ -13,6 +13,7 @@ var IntersectionWatcher = class {
|
|
|
13
13
|
#onEntries;
|
|
14
14
|
#observer = null;
|
|
15
15
|
#active = false;
|
|
16
|
+
#usingPlatformDefaults = false;
|
|
16
17
|
constructor(onEntries) {
|
|
17
18
|
this.#onEntries = onEntries;
|
|
18
19
|
}
|
|
@@ -20,15 +21,22 @@ var IntersectionWatcher = class {
|
|
|
20
21
|
get active() {
|
|
21
22
|
return this.#active;
|
|
22
23
|
}
|
|
24
|
+
/** Whether the live observer discarded configured options after construction failed. */
|
|
25
|
+
get usingPlatformDefaults() {
|
|
26
|
+
return this.#usingPlatformDefaults;
|
|
27
|
+
}
|
|
23
28
|
/**
|
|
24
29
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
25
30
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
26
31
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
32
|
+
* If initial construction with the configured options fails, the watcher
|
|
33
|
+
* warns and retries once with the same root and platform defaults.
|
|
27
34
|
*
|
|
28
|
-
* @throws
|
|
29
|
-
* or
|
|
30
|
-
* the watcher rolls back first: every target observed
|
|
31
|
-
* `active` stays `false`, so a caller that retries
|
|
35
|
+
* @throws The fallback constructor error if both construction attempts fail,
|
|
36
|
+
* or whatever the platform throws from `observe()`. The exception is passed
|
|
37
|
+
* through unchanged, but the watcher rolls back first: every target observed
|
|
38
|
+
* so far is released and `active` stays `false`, so a caller that retries
|
|
39
|
+
* starts from a clean slate.
|
|
32
40
|
*/
|
|
33
41
|
start(targets, options = {}) {
|
|
34
42
|
this.stop();
|
|
@@ -38,12 +46,23 @@ var IntersectionWatcher = class {
|
|
|
38
46
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
39
47
|
let observer = null;
|
|
40
48
|
try {
|
|
41
|
-
|
|
42
|
-
(entries)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
const onEntries = (entries) => {
|
|
50
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
observer = new IntersectionObserver(onEntries, {
|
|
54
|
+
root,
|
|
55
|
+
rootMargin: options.rootMargin,
|
|
56
|
+
threshold: options.threshold
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn(
|
|
60
|
+
"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.",
|
|
61
|
+
error
|
|
62
|
+
);
|
|
63
|
+
observer = new IntersectionObserver(onEntries, { root });
|
|
64
|
+
this.#usingPlatformDefaults = true;
|
|
65
|
+
}
|
|
47
66
|
for (const target of list) observer.observe(target);
|
|
48
67
|
this.#observer = observer;
|
|
49
68
|
this.#active = true;
|
|
@@ -52,6 +71,7 @@ var IntersectionWatcher = class {
|
|
|
52
71
|
observer?.disconnect();
|
|
53
72
|
this.#observer = null;
|
|
54
73
|
this.#active = false;
|
|
74
|
+
this.#usingPlatformDefaults = false;
|
|
55
75
|
throw error;
|
|
56
76
|
}
|
|
57
77
|
}
|
|
@@ -78,6 +98,7 @@ var IntersectionWatcher = class {
|
|
|
78
98
|
this.#active = false;
|
|
79
99
|
this.#observer?.disconnect();
|
|
80
100
|
this.#observer = null;
|
|
101
|
+
this.#usingPlatformDefaults = false;
|
|
81
102
|
}
|
|
82
103
|
};
|
|
83
104
|
|
|
@@ -96,6 +117,8 @@ var IntersectionController = class extends Controller {
|
|
|
96
117
|
static events = ["enter", "exit", "change", "passed"];
|
|
97
118
|
/** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */
|
|
98
119
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
|
|
120
|
+
/** Threshold actually installed in the live observer (0 after option fallback). */
|
|
121
|
+
#effectiveThreshold = 0;
|
|
99
122
|
/** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */
|
|
100
123
|
#generation = 0;
|
|
101
124
|
#onIntersect(entries) {
|
|
@@ -103,7 +126,7 @@ var IntersectionController = class extends Controller {
|
|
|
103
126
|
for (const entry of entries) {
|
|
104
127
|
if (!this.#watcher.active || this.#generation !== generation) return;
|
|
105
128
|
const ratio = entry.intersectionRatio;
|
|
106
|
-
const threshold = this.#
|
|
129
|
+
const threshold = this.#effectiveThreshold;
|
|
107
130
|
const intersecting = threshold > 0 ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON : entry.isIntersecting;
|
|
108
131
|
this.element.style.setProperty(RATIO_PROPERTY, String(ratio));
|
|
109
132
|
this.dispatch("change", { detail: { intersecting, ratio } });
|
|
@@ -113,11 +136,13 @@ var IntersectionController = class extends Controller {
|
|
|
113
136
|
}
|
|
114
137
|
connect() {
|
|
115
138
|
if (this.onceValue && this.element.getAttribute("data-intersecting") === "true") return;
|
|
139
|
+
this.#effectiveThreshold = this.#clampedThreshold();
|
|
116
140
|
this.#watcher.start(this.element, {
|
|
117
141
|
rootSelector: this.rootSelectorValue,
|
|
118
142
|
rootMargin: this.rootMarginValue,
|
|
119
143
|
threshold: this.#thresholds()
|
|
120
144
|
});
|
|
145
|
+
if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;
|
|
121
146
|
}
|
|
122
147
|
disconnect() {
|
|
123
148
|
this.#watcher.stop();
|
|
@@ -7,6 +7,7 @@ var IntersectionWatcher = class {
|
|
|
7
7
|
#onEntries;
|
|
8
8
|
#observer = null;
|
|
9
9
|
#active = false;
|
|
10
|
+
#usingPlatformDefaults = false;
|
|
10
11
|
constructor(onEntries) {
|
|
11
12
|
this.#onEntries = onEntries;
|
|
12
13
|
}
|
|
@@ -14,15 +15,22 @@ var IntersectionWatcher = class {
|
|
|
14
15
|
get active() {
|
|
15
16
|
return this.#active;
|
|
16
17
|
}
|
|
18
|
+
/** Whether the live observer discarded configured options after construction failed. */
|
|
19
|
+
get usingPlatformDefaults() {
|
|
20
|
+
return this.#usingPlatformDefaults;
|
|
21
|
+
}
|
|
17
22
|
/**
|
|
18
23
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
24
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
25
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
26
|
+
* If initial construction with the configured options fails, the watcher
|
|
27
|
+
* warns and retries once with the same root and platform defaults.
|
|
21
28
|
*
|
|
22
|
-
* @throws
|
|
23
|
-
* or
|
|
24
|
-
* the watcher rolls back first: every target observed
|
|
25
|
-
* `active` stays `false`, so a caller that retries
|
|
29
|
+
* @throws The fallback constructor error if both construction attempts fail,
|
|
30
|
+
* or whatever the platform throws from `observe()`. The exception is passed
|
|
31
|
+
* through unchanged, but the watcher rolls back first: every target observed
|
|
32
|
+
* so far is released and `active` stays `false`, so a caller that retries
|
|
33
|
+
* starts from a clean slate.
|
|
26
34
|
*/
|
|
27
35
|
start(targets, options = {}) {
|
|
28
36
|
this.stop();
|
|
@@ -32,12 +40,23 @@ var IntersectionWatcher = class {
|
|
|
32
40
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
33
41
|
let observer = null;
|
|
34
42
|
try {
|
|
35
|
-
|
|
36
|
-
(entries)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
const onEntries = (entries) => {
|
|
44
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
observer = new IntersectionObserver(onEntries, {
|
|
48
|
+
root,
|
|
49
|
+
rootMargin: options.rootMargin,
|
|
50
|
+
threshold: options.threshold
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.warn(
|
|
54
|
+
"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.",
|
|
55
|
+
error
|
|
56
|
+
);
|
|
57
|
+
observer = new IntersectionObserver(onEntries, { root });
|
|
58
|
+
this.#usingPlatformDefaults = true;
|
|
59
|
+
}
|
|
41
60
|
for (const target of list) observer.observe(target);
|
|
42
61
|
this.#observer = observer;
|
|
43
62
|
this.#active = true;
|
|
@@ -46,6 +65,7 @@ var IntersectionWatcher = class {
|
|
|
46
65
|
observer?.disconnect();
|
|
47
66
|
this.#observer = null;
|
|
48
67
|
this.#active = false;
|
|
68
|
+
this.#usingPlatformDefaults = false;
|
|
49
69
|
throw error;
|
|
50
70
|
}
|
|
51
71
|
}
|
|
@@ -72,6 +92,7 @@ var IntersectionWatcher = class {
|
|
|
72
92
|
this.#active = false;
|
|
73
93
|
this.#observer?.disconnect();
|
|
74
94
|
this.#observer = null;
|
|
95
|
+
this.#usingPlatformDefaults = false;
|
|
75
96
|
}
|
|
76
97
|
};
|
|
77
98
|
|