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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +160 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +32 -5
  5. data/dist/controllers/announcer_controller.js +255 -20
  6. data/dist/controllers/breadcrumb_controller.js +225 -13
  7. data/dist/controllers/calendar_controller.js +89 -22
  8. data/dist/controllers/carousel_controller.js +47 -6
  9. data/dist/controllers/collapsible_controller.js +2 -2
  10. data/dist/controllers/color_picker_controller.js +92 -7
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +226 -22
  13. data/dist/controllers/confirm_controller.js +32 -5
  14. data/dist/controllers/context_menu_controller.js +32 -10
  15. data/dist/controllers/countdown_controller.js +112 -12
  16. data/dist/controllers/data_grid_controller.js +82 -4
  17. data/dist/controllers/date_range_picker_controller.js +77 -3
  18. data/dist/controllers/dialog_controller.js +32 -5
  19. data/dist/controllers/drawer_controller.js +32 -5
  20. data/dist/controllers/editable_controller.js +1 -0
  21. data/dist/controllers/empty_state_controller.js +24 -10
  22. data/dist/controllers/focus_controller.js +32 -5
  23. data/dist/controllers/form_validation_controller.js +1 -1
  24. data/dist/controllers/frame_loading_controller.js +177 -15
  25. data/dist/controllers/intersection_controller.js +36 -11
  26. data/dist/controllers/lazy_frame_controller.js +31 -10
  27. data/dist/controllers/listbox_controller.js +257 -53
  28. data/dist/controllers/local_time_controller.js +102 -8
  29. data/dist/controllers/menu_controller.js +104 -17
  30. data/dist/controllers/menubar_controller.js +415 -63
  31. data/dist/controllers/meter_controller.js +145 -26
  32. data/dist/controllers/multi_select_controller.js +312 -29
  33. data/dist/controllers/navigation_menu_controller.js +154 -27
  34. data/dist/controllers/network_status_controller.js +28 -8
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +81 -13
  38. data/dist/controllers/overflow_menu_controller.js +408 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/persist_controller.js +6 -6
  41. data/dist/controllers/pointer_drag_controller.js +9 -1
  42. data/dist/controllers/popover_controller.js +2 -2
  43. data/dist/controllers/progress_controller.js +116 -9
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +100 -9
  46. data/dist/controllers/rating_controller.js +69 -2
  47. data/dist/controllers/read_more_controller.js +63 -19
  48. data/dist/controllers/relative_time_controller.js +133 -12
  49. data/dist/controllers/resizable_controller.js +65 -1
  50. data/dist/controllers/roving_controller.js +17 -2
  51. data/dist/controllers/scroll_area_controller.js +86 -12
  52. data/dist/controllers/scroll_restore_controller.js +1 -1
  53. data/dist/controllers/scroll_visibility_controller.js +33 -3
  54. data/dist/controllers/scrollspy_controller.js +346 -73
  55. data/dist/controllers/separator_controller.js +9 -0
  56. data/dist/controllers/sidebar_controller.js +37 -8
  57. data/dist/controllers/skeleton_controller.js +73 -20
  58. data/dist/controllers/slider_controller.js +49 -8
  59. data/dist/controllers/sortable_controller.js +34 -3
  60. data/dist/controllers/spinner_controller.js +228 -27
  61. data/dist/controllers/step_indicator_controller.js +82 -5
  62. data/dist/controllers/stick_to_bottom_controller.js +61 -10
  63. data/dist/controllers/sticky_observer_controller.js +32 -11
  64. data/dist/controllers/switch_controller.js +1 -0
  65. data/dist/controllers/tabs_controller.js +26 -3
  66. data/dist/controllers/tags_input_controller.js +22 -2
  67. data/dist/controllers/theme_controller.js +22 -3
  68. data/dist/controllers/time_picker_controller.js +20 -1
  69. data/dist/controllers/toast_controller.js +4 -5
  70. data/dist/controllers/toggle_group_controller.js +23 -2
  71. data/dist/controllers/toolbar_controller.js +230 -31
  72. data/dist/controllers/tree_view_controller.js +467 -51
  73. data/dist/index.js +4507 -907
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. 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
- document.addEventListener("turbo:before-cache", this.#onBeforeCache);
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
- document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
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
- #onBeforeCache = () => {
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 (status quo for native fields and radios). A validatable mirror
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.#timeouts.clearAll();
217
+ this.#floor.cancel();
75
218
  if (!this.#loading) this.#begin();
76
219
  };
77
220
  #onEnd = () => {
78
221
  if (!this.#loading) return;
79
- const remaining = this.minDurationValue - (Date.now() - this.#startedAt);
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
- if (this.#loading) {
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.#startedAt = Date.now();
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 Whatever the platform throws for an invalid `rootMargin`/`threshold`
29
- * or a failing `observe()`. The exception is passed through unchanged, but
30
- * the watcher rolls back first: every target observed so far is released and
31
- * `active` stays `false`, so a caller that retries starts from a clean slate.
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
- observer = new IntersectionObserver(
42
- (entries) => {
43
- if (this.#active && this.#observer === observer) this.#onEntries(entries);
44
- },
45
- { root, rootMargin: options.rootMargin, threshold: options.threshold }
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.#clampedThreshold();
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 Whatever the platform throws for an invalid `rootMargin`/`threshold`
23
- * or a failing `observe()`. The exception is passed through unchanged, but
24
- * the watcher rolls back first: every target observed so far is released and
25
- * `active` stays `false`, so a caller that retries starts from a clean slate.
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
- observer = new IntersectionObserver(
36
- (entries) => {
37
- if (this.#active && this.#observer === observer) this.#onEntries(entries);
38
- },
39
- { root, rootMargin: options.rootMargin, threshold: options.threshold }
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