stimeo-ui 0.16.0 → 0.17.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +8 -1
  4. data/dist/cable/index.js +363 -436
  5. data/dist/controllers/accordion_controller.js +166 -55
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -198
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +271 -127
  10. data/dist/controllers/avatar_controller.js +171 -105
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +262 -52
  13. data/dist/controllers/calendar_controller.js +175 -209
  14. data/dist/controllers/carousel_controller.js +461 -399
  15. data/dist/controllers/character_counter_controller.js +246 -156
  16. data/dist/controllers/checkbox_controller.js +32 -74
  17. data/dist/controllers/clipboard_controller.js +168 -147
  18. data/dist/controllers/collapsible_controller.js +301 -120
  19. data/dist/controllers/color_picker_controller.js +149 -139
  20. data/dist/controllers/combobox_controller.js +360 -133
  21. data/dist/controllers/command_palette_controller.js +970 -358
  22. data/dist/controllers/conditional_fields_controller.js +166 -124
  23. data/dist/controllers/confirm_controller.js +939 -248
  24. data/dist/controllers/context_menu_controller.js +256 -128
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +183 -148
  27. data/dist/controllers/currency_input_controller.js +134 -158
  28. data/dist/controllers/data_grid_controller.js +114 -186
  29. data/dist/controllers/date_range_picker_controller.js +288 -206
  30. data/dist/controllers/dialog_controller.js +858 -223
  31. data/dist/controllers/direct_upload_controller.js +73 -153
  32. data/dist/controllers/dirty_form_controller.js +14 -48
  33. data/dist/controllers/dismissible_controller.js +175 -21
  34. data/dist/controllers/drawer_controller.js +933 -341
  35. data/dist/controllers/dropdown_controller.js +312 -82
  36. data/dist/controllers/editable_controller.js +158 -68
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +311 -207
  39. data/dist/controllers/filter_controller.js +67 -39
  40. data/dist/controllers/flash_controller.js +172 -314
  41. data/dist/controllers/focus_controller.js +654 -240
  42. data/dist/controllers/form_field_controller.js +147 -132
  43. data/dist/controllers/form_validation_controller.js +13 -86
  44. data/dist/controllers/frame_loading_controller.js +264 -213
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +309 -118
  47. data/dist/controllers/idle_controller.js +267 -101
  48. data/dist/controllers/input_mask_controller.js +71 -72
  49. data/dist/controllers/intersection_controller.js +147 -106
  50. data/dist/controllers/lazy_frame_controller.js +9 -65
  51. data/dist/controllers/listbox_controller.js +306 -204
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -93
  54. data/dist/controllers/menu_controller.js +214 -156
  55. data/dist/controllers/menubar_controller.js +170 -329
  56. data/dist/controllers/meter_controller.js +151 -67
  57. data/dist/controllers/multi_select_controller.js +593 -358
  58. data/dist/controllers/navigation_menu_controller.js +124 -232
  59. data/dist/controllers/nested_form_controller.js +352 -182
  60. data/dist/controllers/network_status_controller.js +242 -54
  61. data/dist/controllers/number_input_controller.js +320 -263
  62. data/dist/controllers/optimistic_controller.js +16 -76
  63. data/dist/controllers/otp_controller.js +198 -193
  64. data/dist/controllers/overflow_indicator_controller.js +116 -113
  65. data/dist/controllers/overflow_menu_controller.js +343 -283
  66. data/dist/controllers/pagination_controller.js +295 -119
  67. data/dist/controllers/password_reveal_controller.js +389 -121
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +237 -103
  70. data/dist/controllers/pointer_drag_controller.js +185 -165
  71. data/dist/controllers/popover_controller.js +236 -97
  72. data/dist/controllers/portal_controller.js +209 -76
  73. data/dist/controllers/preview_guard_controller.js +232 -116
  74. data/dist/controllers/progress_controller.js +125 -56
  75. data/dist/controllers/radio_group_controller.js +117 -137
  76. data/dist/controllers/range_slider_controller.js +192 -105
  77. data/dist/controllers/rating_controller.js +418 -207
  78. data/dist/controllers/read_more_controller.js +134 -63
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +255 -139
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -225
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -145
  87. data/dist/controllers/scrollspy_controller.js +181 -242
  88. data/dist/controllers/separator_controller.js +267 -140
  89. data/dist/controllers/sidebar_controller.js +1023 -386
  90. data/dist/controllers/skeleton_controller.js +248 -97
  91. data/dist/controllers/slider_controller.js +159 -79
  92. data/dist/controllers/smart_sticky_header_controller.js +162 -63
  93. data/dist/controllers/sortable_controller.js +0 -112
  94. data/dist/controllers/spinner_controller.js +277 -169
  95. data/dist/controllers/step_indicator_controller.js +99 -46
  96. data/dist/controllers/stepper_controller.js +114 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +262 -138
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -205
  100. data/dist/controllers/switch_controller.js +33 -60
  101. data/dist/controllers/tabs_controller.js +35 -29
  102. data/dist/controllers/tags_input_controller.js +136 -145
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +130 -112
  105. data/dist/controllers/time_picker_controller.js +146 -80
  106. data/dist/controllers/toast_controller.js +186 -297
  107. data/dist/controllers/toggle_group_controller.js +95 -136
  108. data/dist/controllers/toolbar_controller.js +68 -98
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +150 -345
  112. data/dist/index.js +6370 -9346
  113. data/dist/positioning/index.js +148 -47
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,8 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/input_mask_controller.ts
4
-
5
- // src/utils/composition_tracker.ts
6
3
  var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
7
4
  "insertCompositionText",
8
5
  "insertFromComposition",
@@ -14,13 +11,11 @@ var CompositionTracker = class {
14
11
  #activeTargets = /* @__PURE__ */ new Set();
15
12
  #onStart;
16
13
  #onEnd;
17
- /** The field whose confirming `input` is still owed, while the window is open. */
18
14
  #confirmedTarget = null;
19
15
  constructor(options = {}) {
20
16
  this.#onStart = options.onStart;
21
17
  this.#onEnd = options.onEnd;
22
18
  }
23
- /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
24
19
  observe(target) {
25
20
  if (this.#observedTargets.has(target)) return;
26
21
  target.addEventListener("compositionstart", this.#handleStart);
@@ -28,7 +23,6 @@ var CompositionTracker = class {
28
23
  target.addEventListener("keydown", this.#handleKeydown);
29
24
  this.#observedTargets.add(target);
30
25
  }
31
- /** Stops tracking one target and clears any active composition it owned. */
32
26
  unobserve(target) {
33
27
  if (!this.#observedTargets.delete(target)) return;
34
28
  target.removeEventListener("compositionstart", this.#handleStart);
@@ -37,7 +31,6 @@ var CompositionTracker = class {
37
31
  this.#activeTargets.delete(target);
38
32
  if (this.#confirmedTarget === target) this.#confirmedTarget = null;
39
33
  }
40
- /** Releases every listener and clears state so reconnect starts cleanly. */
41
34
  disconnect() {
42
35
  for (const target of this.#observedTargets) {
43
36
  target.removeEventListener("compositionstart", this.#handleStart);
@@ -48,16 +41,9 @@ var CompositionTracker = class {
48
41
  this.#activeTargets.clear();
49
42
  this.#confirmedTarget = null;
50
43
  }
51
- /** True when lifecycle tracking or the current event reports composition. */
52
44
  isComposing(event) {
53
45
  return this.#activeTargets.size > 0 || event?.isComposing === true;
54
46
  }
55
- /**
56
- * Whether `event` is the `input` echoing the composition just confirmed.
57
- *
58
- * Asking closes the window either way, so one confirmation is folded at most
59
- * once and a consumer asks once per `input`.
60
- */
61
47
  consumesConfirmedInput(event) {
62
48
  const confirmed = this.#confirmedTarget;
63
49
  this.#confirmedTarget = null;
@@ -75,13 +61,11 @@ var CompositionTracker = class {
75
61
  this.#confirmedTarget = event.target;
76
62
  this.#onEnd?.(event);
77
63
  };
78
- /** A key on an observed field opens an edit of its own, so no echo is owed. */
79
64
  #handleKeydown = () => {
80
65
  this.#confirmedTarget = null;
81
66
  };
82
67
  };
83
68
 
84
- // src/utils/declared_value.ts
85
69
  function parseDeclared(raw, parse, fallback) {
86
70
  try {
87
71
  return parse(raw);
@@ -105,7 +89,6 @@ function parseJsonObject(raw) {
105
89
  return parsed;
106
90
  }
107
91
 
108
- // src/utils/focus_candidate.ts
109
92
  function inheritsFieldsetDisabled(control) {
110
93
  let fieldset = control.closest("fieldset[disabled]");
111
94
  while (fieldset) {
@@ -116,7 +99,6 @@ function inheritsFieldsetDisabled(control) {
116
99
  return false;
117
100
  }
118
101
 
119
- // src/utils/half_width.ts
120
102
  var FULL_WIDTH_SHIFT = 65248;
121
103
  function halfWidthChar(char) {
122
104
  if (char >= "\uFF01" && char <= "\uFF5E") {
@@ -125,7 +107,70 @@ function halfWidthChar(char) {
125
107
  return char === "\u3000" ? " " : char;
126
108
  }
127
109
 
128
- // src/controllers/input_mask_controller.ts
110
+ var ListenerSet = class {
111
+ #abort = new AbortController();
112
+ add(target, type, handler, options) {
113
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
114
+ }
115
+ dispose() {
116
+ this.#abort.abort();
117
+ this.#abort = new AbortController();
118
+ }
119
+ };
120
+
121
+ var MicrotaskCoalescer = class {
122
+ #run;
123
+ #queued = false;
124
+ #active = false;
125
+ #generation = 0;
126
+ constructor(run) {
127
+ this.#run = run;
128
+ }
129
+ activate() {
130
+ this.#active = true;
131
+ }
132
+ cancel() {
133
+ this.#active = false;
134
+ this.#queued = false;
135
+ this.#generation += 1;
136
+ }
137
+ schedule() {
138
+ if (!this.#active || this.#queued) return;
139
+ this.#queued = true;
140
+ const generation = this.#generation;
141
+ queueMicrotask(() => {
142
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
143
+ this.#queued = false;
144
+ this.#run();
145
+ });
146
+ }
147
+ };
148
+
149
+ var MorphRenderWatcher = class {
150
+ #listeners = new ListenerSet();
151
+ #pass;
152
+ #element = null;
153
+ #onMorph = () => this.#pass.schedule();
154
+ constructor(run) {
155
+ this.#pass = new MicrotaskCoalescer(run);
156
+ }
157
+ observe(element) {
158
+ if (this.#element === element) return;
159
+ this.disconnect();
160
+ this.#element = element;
161
+ this.#pass.activate();
162
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
163
+ }
164
+ schedule() {
165
+ this.#pass.schedule();
166
+ }
167
+ disconnect() {
168
+ this.#listeners.dispose();
169
+ this.#pass.cancel();
170
+ this.#element = null;
171
+ }
172
+ };
173
+
129
174
  var DEFAULT_TOKENS = {
130
175
  "9": "\\d",
131
176
  a: "[A-Za-z]",
@@ -208,7 +253,6 @@ function tokenCharOffset(tokenFlags, n) {
208
253
  return -1;
209
254
  }
210
255
  var InputMaskController = class extends Controller {
211
- /** The attribute above, in the namespace this controller is registered under. */
212
256
  get #unmaskAttribute() {
213
257
  return `data-${this.identifier}-${UNMASK_ATTR}`;
214
258
  }
@@ -219,19 +263,10 @@ var InputMaskController = class extends Controller {
219
263
  };
220
264
  static actions = ["format"];
221
265
  static events = ["change", "reconcile"];
222
- /** The value this controller last committed, and the baseline both events compare. */
223
266
  #lastValue = null;
224
267
  #started = false;
225
- /** Validated token map; the hot path never parses the `tokens` declaration. */
226
268
  #tokens = compileTokens("");
227
- /** The field's text when the running composition began. */
228
269
  #textBeforeComposition = "";
229
- /**
230
- * Holds mid-composition input so the IME's uncommitted text is never rewritten,
231
- * and formats the confirmed text once when the composition ends. A composition
232
- * that ends on the text it began with committed nothing, so a re-format then is
233
- * the page's: the mask changed while it ran.
234
- */
235
270
  #composition = new CompositionTracker({
236
271
  onStart: () => {
237
272
  this.#textBeforeComposition = this.element.value;
@@ -241,7 +276,6 @@ var InputMaskController = class extends Controller {
241
276
  this.#reformat(committedNothing ? "reconcile" : "edit");
242
277
  }
243
278
  });
244
- /** Re-parses the declaration and re-formats under the tokens it now selects. */
245
279
  tokensValueChanged() {
246
280
  this.#tokens = compileTokens(this.tokensValue);
247
281
  if (this.#started) this.#reformat("reconcile");
@@ -249,7 +283,6 @@ var InputMaskController = class extends Controller {
249
283
  patternValueChanged() {
250
284
  if (this.#started) this.#reformat("reconcile");
251
285
  }
252
- /** Clears a sink it stops maintaining so a submit cannot carry a stale raw value. */
253
286
  unmaskToHiddenValueChanged() {
254
287
  if (!this.#started) return;
255
288
  if (!this.unmaskToHiddenValue) {
@@ -258,27 +291,23 @@ var InputMaskController = class extends Controller {
258
291
  }
259
292
  this.#reformat("reconcile");
260
293
  }
294
+ #morph = new MorphRenderWatcher(() => this.#reformat("reconcile"));
261
295
  connect() {
296
+ this.#morph.observe(this.element);
262
297
  this.#started = true;
263
298
  this.#composition.observe(this.element);
264
299
  this.#lastValue = this.element.value;
265
300
  this.#reformat("reconcile");
266
301
  }
267
302
  disconnect() {
303
+ this.#morph.disconnect();
268
304
  this.#started = false;
269
305
  this.#composition.disconnect();
270
306
  }
271
- /** Formats the field on input, preserving the caret. Bound via `data-action`. */
272
307
  format(event) {
273
308
  if (this.#composition.isComposing(event)) return;
274
309
  this.#reformat("edit", deletionOf(event));
275
310
  }
276
- /**
277
- * Core reformat: mask the current value, restore the caret, sync the sink and
278
- * the state hooks, and report a moved value under the event `cause` selects.
279
- *
280
- * @stimeoRenderRoot
281
- */
282
311
  #reformat(cause, deletion = null) {
283
312
  if (this.#composition.isComposing()) return;
284
313
  const input = this.element;
@@ -309,8 +338,10 @@ var InputMaskController = class extends Controller {
309
338
  this.#lastValue = raw;
310
339
  return;
311
340
  }
312
- input.value = result.masked;
313
- this.#restoreCaret(result.tokenFlags, anchor);
341
+ if (input.value !== result.masked) {
342
+ input.value = result.masked;
343
+ this.#restoreCaret(result.tokenFlags, anchor);
344
+ }
314
345
  this.#publish(result, result.masked);
315
346
  if (result.masked === this.#lastValue) return;
316
347
  this.#lastValue = result.masked;
@@ -318,7 +349,6 @@ var InputMaskController = class extends Controller {
318
349
  if (cause === "edit") this.dispatch("change", { detail });
319
350
  else this.dispatch("reconcile", { detail });
320
351
  }
321
- /** Syncs the raw-value sink and the state hooks for the `shown` field text. */
322
352
  #publish(result, shown) {
323
353
  if (this.unmaskToHiddenValue) {
324
354
  const sink = this.#resolveSink();
@@ -327,15 +357,9 @@ var InputMaskController = class extends Controller {
327
357
  this.#flag("data-mask-complete", result.complete);
328
358
  this.#flag("data-mask-empty", shown.length === 0);
329
359
  }
330
- /**
331
- * How many token slots the text before the caret fills. Masking that prefix is
332
- * what makes a rejected character — or a literal that also matches a token —
333
- * count exactly as the rendering counts it, so the caret cannot drift.
334
- */
335
360
  #significantBefore(prefix) {
336
361
  return applyMask(prefix, this.patternValue, this.#tokens).unmasked.length;
337
362
  }
338
- /** Places the caret after the `n`-th token char (skipping following literals). */
339
363
  #restoreCaret(tokenFlags, n) {
340
364
  let position;
341
365
  if (n <= 0) {
@@ -357,23 +381,6 @@ var InputMaskController = class extends Controller {
357
381
  } catch {
358
382
  }
359
383
  }
360
- /**
361
- * The hidden raw-value sink for this input, resolved so several masked inputs
362
- * can coexist in one form:
363
- *
364
- * 1. **Explicit pairing** — a sink whose attribute value names this input's
365
- * `id` (`data-stimeo--input-mask-unmask="zip"`).
366
- * 2. **Nearest container** — otherwise, walking up from the input, the first
367
- * *value-less* sink in the closest ancestor. Wrapped input+sink pairs each
368
- * find their own sink, and a single form-level sink is found the same
369
- * way. A sink claimed by another input's id is never matched here.
370
- *
371
- * Both steps only ever consider the associated form's own controls, so a sink
372
- * belonging to a different form (or to none) is never written, while one the
373
- * `form` attribute associates from elsewhere in the document still resolves —
374
- * for either the input or the sink, neither of which needs to contain or be
375
- * contained by the form. A form-less input reads the document instead.
376
- */
377
384
  #resolveSink() {
378
385
  const candidates = this.#sinkCandidates();
379
386
  const id = this.element.id;
@@ -389,12 +396,6 @@ var InputMaskController = class extends Controller {
389
396
  }
390
397
  return null;
391
398
  }
392
- /**
393
- * Every sink the input's form owns, in document order — one collection read per
394
- * resolution, so the cost tracks the form's own controls and not the depth of
395
- * the markup around it. `form.elements` lists controls the `form` attribute
396
- * associates from anywhere in the document, and lists nothing another form owns.
397
- */
398
399
  #sinkCandidates() {
399
400
  const form = this.element.form;
400
401
  if (form === null) {
@@ -406,7 +407,6 @@ var InputMaskController = class extends Controller {
406
407
  (element) => element instanceof HTMLInputElement && element.hasAttribute(this.#unmaskAttribute)
407
408
  );
408
409
  }
409
- /** Sets a boolean `data-*` flag to `"true"` when `on`, else removes it. */
410
410
  #flag(name, on) {
411
411
  if (on) this.element.setAttribute(name, "true");
412
412
  else this.element.removeAttribute(name);
@@ -414,5 +414,4 @@ var InputMaskController = class extends Controller {
414
414
  };
415
415
 
416
416
  export { InputMaskController, applyMask };
417
- //# sourceMappingURL=input_mask_controller.js.map
418
417
  //# sourceMappingURL=input_mask_controller.js.map
@@ -1,8 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/intersection_controller.ts
4
-
5
- // src/utils/declared_value.ts
6
3
  function parseDeclared(raw, parse, fallback) {
7
4
  try {
8
5
  return parse(raw);
@@ -10,8 +7,18 @@ function parseDeclared(raw, parse, fallback) {
10
7
  return fallback;
11
8
  }
12
9
  }
10
+ function validSelector(element, raw, fallback) {
11
+ if (raw.length === 0) return fallback;
12
+ return parseDeclared(
13
+ raw,
14
+ (selector) => {
15
+ element.matches(selector);
16
+ return selector;
17
+ },
18
+ fallback
19
+ );
20
+ }
13
21
 
14
- // src/utils/intersection_watcher.ts
15
22
  function isBeforeRootStart(entry) {
16
23
  const rect = entry.boundingClientRect;
17
24
  if (rect.width === 0 && rect.height === 0) return false;
@@ -30,30 +37,12 @@ var IntersectionWatcher = class {
30
37
  constructor(onEntries) {
31
38
  this.#onEntries = onEntries;
32
39
  }
33
- /** Whether an observer is live (started, `IntersectionObserver` supported). */
34
40
  get active() {
35
41
  return this.#active;
36
42
  }
37
- /** Whether the live observer discarded configured options after construction failed. */
38
43
  get usingPlatformDefaults() {
39
44
  return this.#usingPlatformDefaults;
40
45
  }
41
- /**
42
- * (Re)creates the observer and observes `targets`. Returns `false` — leaving
43
- * the watcher inert — without `IntersectionObserver` support (very old
44
- * browsers; the caller's no-JS fallback stays in charge) or with no targets.
45
- * If initial construction with the configured options fails, the watcher
46
- * warns and retries once with the same root and platform defaults. A
47
- * `rootSelector` that does not parse resolves to the viewport (see
48
- * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the
49
- * call.
50
- *
51
- * @throws The fallback constructor error if both construction attempts fail,
52
- * or whatever the platform throws from `observe()`. The exception is passed
53
- * through unchanged, but the watcher rolls back first: every target observed
54
- * so far is released and `active` stays `false`, so a caller that retries
55
- * starts from a clean slate.
56
- */
57
46
  start(targets, options = {}) {
58
47
  this.stop();
59
48
  if (typeof IntersectionObserver === "undefined") return false;
@@ -91,14 +80,6 @@ var IntersectionWatcher = class {
91
80
  throw error;
92
81
  }
93
82
  }
94
- /**
95
- * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
96
- * only reports *changes*, but `observe()` always reports the present state,
97
- * so unobserve→observe turns "still intersecting" into a fresh callback.
98
- *
99
- * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
100
- * first, so it never stays live with a half-rearmed target.
101
- */
102
83
  rearm(target) {
103
84
  if (!this.#observer) return;
104
85
  try {
@@ -109,7 +90,6 @@ var IntersectionWatcher = class {
109
90
  throw error;
110
91
  }
111
92
  }
112
- /** Severs the observer; late queued callbacks become no-ops via the guard. */
113
93
  stop() {
114
94
  this.#active = false;
115
95
  this.#observer?.disconnect();
@@ -118,10 +98,82 @@ var IntersectionWatcher = class {
118
98
  }
119
99
  };
120
100
 
121
- // src/controllers/intersection_controller.ts
101
+ var MicrotaskCoalescer = class {
102
+ #run;
103
+ #queued = false;
104
+ #active = false;
105
+ #generation = 0;
106
+ constructor(run) {
107
+ this.#run = run;
108
+ }
109
+ activate() {
110
+ this.#active = true;
111
+ }
112
+ cancel() {
113
+ this.#active = false;
114
+ this.#queued = false;
115
+ this.#generation += 1;
116
+ }
117
+ schedule() {
118
+ if (!this.#active || this.#queued) return;
119
+ this.#queued = true;
120
+ const generation = this.#generation;
121
+ queueMicrotask(() => {
122
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
123
+ this.#queued = false;
124
+ this.#run();
125
+ });
126
+ }
127
+ };
128
+
129
+ var NUMBER_BOUNDS = {
130
+ finite: { finite: true }};
131
+ function matchesNumberBounds(value, bounds) {
132
+ if (!Number.isFinite(value)) {
133
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
134
+ if (direction === null) return false;
135
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
136
+ }
137
+ if (bounds.min !== void 0 && value < bounds.min) return false;
138
+ if (bounds.max !== void 0 && value > bounds.max) return false;
139
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
140
+ if (bounds.integer && !Number.isInteger(value)) return false;
141
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
142
+ return true;
143
+ }
144
+
145
+ function readNumber(raw, fallback, bounds) {
146
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
147
+ }
148
+
149
+ var NumberValueReader = class {
150
+ #lastRejected = /* @__PURE__ */ new Map();
151
+ read(owner, name, raw, fallback, bounds) {
152
+ const resolved = readNumber(raw, fallback, bounds);
153
+ if (matchesNumberBounds(raw, bounds)) {
154
+ this.#lastRejected.delete(name);
155
+ return resolved;
156
+ }
157
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
158
+ const literal = owner.element.getAttribute(attribute);
159
+ if (literal === null) {
160
+ this.#lastRejected.delete(name);
161
+ return resolved;
162
+ }
163
+ if (this.#lastRejected.get(name) !== literal) {
164
+ this.#lastRejected.set(name, literal);
165
+ console.warn(
166
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
167
+ );
168
+ }
169
+ return resolved;
170
+ }
171
+ };
172
+
122
173
  var RATIO_PROPERTY = "--stimeo--intersection-ratio";
123
174
  var RATIO_EPSILON = 0.01;
124
- var IntersectionController = class extends Controller {
175
+ var IntersectionController = class _IntersectionController extends Controller {
176
+ #numbers = new NumberValueReader();
125
177
  static values = {
126
178
  threshold: { type: Number, default: 0 },
127
179
  ratioSteps: { type: Number, default: 0 },
@@ -129,13 +181,18 @@ var IntersectionController = class extends Controller {
129
181
  rootSelector: { type: String, default: "" },
130
182
  once: { type: Boolean, default: false }
131
183
  };
184
+ static valueConstraints = {
185
+ threshold: NUMBER_BOUNDS.finite,
186
+ ratioSteps: { finite: true, min: 0, max: 1e3 }
187
+ };
132
188
  static actions = ["refresh"];
133
189
  static events = ["enter", "exit", "change", "passed"];
134
- /** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */
135
190
  #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
136
- /** Threshold actually installed in the live observer (0 after option fallback). */
191
+ #rebuild = new MicrotaskCoalescer(() => this.#sync());
192
+ #rootSelector = "";
193
+ #builtRoot = null;
194
+ #builtOptions = "";
137
195
  #effectiveThreshold = 0;
138
- /** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */
139
196
  #generation = 0;
140
197
  #onIntersect(entries) {
141
198
  const generation = this.#generation;
@@ -151,46 +208,49 @@ var IntersectionController = class extends Controller {
151
208
  }
152
209
  }
153
210
  connect() {
154
- if (this.onceValue && this.element.getAttribute("data-intersecting") === "true") return;
155
- this.#observe();
211
+ this.#rebuild.activate();
212
+ this.#sync();
156
213
  }
157
214
  disconnect() {
215
+ this.#rebuild.cancel();
158
216
  this.#watcher.stop();
159
217
  }
160
- /**
161
- * Re-reads the visibility line and rebuilds the observer. Turbo 8 morphing
162
- * rewrites the attribute in place without a reconnect, and the line is what
163
- * the intersection callback compares every ratio against, so a value frozen at
164
- * connect time would decide `data-intersecting` wrongly for the rest of the
165
- * page's life. Nothing to rebuild before the first `connect()`; after a spent
166
- * one-shot the watcher is deliberately stopped, and re-observing would deliver
167
- * the current state and fire `enter` a second time.
168
- */
169
218
  thresholdValueChanged() {
170
- if (this.#watcher.active) this.#observe();
219
+ this.#rebuild.schedule();
220
+ }
221
+ ratioStepsValueChanged() {
222
+ this.#rebuild.schedule();
223
+ }
224
+ rootMarginValueChanged() {
225
+ this.#rebuild.schedule();
226
+ }
227
+ rootSelectorValueChanged() {
228
+ this.#rootSelector = validSelector(this.element, this.rootSelectorValue, "");
229
+ this.#rebuild.schedule();
171
230
  }
172
- /** (Re)installs the observer from the current Values. */
173
- #observe() {
174
- this.#effectiveThreshold = this.#clampedThreshold();
231
+ onceValueChanged() {
232
+ this.#rebuild.schedule();
233
+ }
234
+ #sync() {
235
+ if (this.onceValue && this.element.getAttribute("data-intersecting") === "true") {
236
+ this.#watcher.stop();
237
+ return;
238
+ }
239
+ const root = this.#rootSelector ? document.querySelector(this.#rootSelector) : null;
240
+ const threshold = this.#clampedThreshold();
241
+ const thresholds = this.#thresholds();
242
+ const options = `${this.rootMarginValue} ${threshold} ${thresholds.join(",")}`;
243
+ if (this.#watcher.active && root === this.#builtRoot && options === this.#builtOptions) return;
244
+ this.#builtRoot = root;
245
+ this.#builtOptions = options;
246
+ this.#effectiveThreshold = threshold;
175
247
  this.#watcher.start(this.element, {
176
- rootSelector: this.rootSelectorValue,
248
+ root,
177
249
  rootMargin: this.rootMarginValue,
178
- threshold: this.#thresholds()
250
+ threshold: thresholds
179
251
  });
180
252
  if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;
181
253
  }
182
- /**
183
- * Re-delivers the current intersection state as a fresh transition. Bound via
184
- * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).
185
- *
186
- * `IntersectionObserver` only reports state *changes*, so a sentinel that
187
- * stays visible while content is appended below it never fires `enter` again
188
- * and a hand-rolled infinite scroll stalls. `observe()` always delivers the
189
- * current state, and clearing the recorded `data-intersecting`/`data-passed`
190
- * makes that delivery count as a transition — a still-visible sentinel
191
- * re-fires `enter`. No-op once the observer is gone (`once` fired, no
192
- * `IntersectionObserver` support, or after `disconnect()`).
193
- */
194
254
  refresh() {
195
255
  if (!this.#watcher.active) return;
196
256
  this.#generation += 1;
@@ -198,16 +258,6 @@ var IntersectionController = class extends Controller {
198
258
  this.element.removeAttribute("data-passed");
199
259
  this.#watcher.rearm(this.element);
200
260
  }
201
- /**
202
- * Reflects the visibility onto `data-intersecting` and fires `enter`/`exit`
203
- * on transitions. The previous state is the DOM attribute (source of truth),
204
- * so the observer's initial callback fires `enter` for an element that starts
205
- * visible but stays silent after a cache restore that already recorded it.
206
- * An initial not-visible state is established silently (no `exit`).
207
- *
208
- * @stimeoRuntimeOnly `once` decides whether this enter spends the watcher's one shot; the hook it
209
- * writes follows the entry.
210
- */
211
261
  #syncIntersecting(intersecting, ratio, entry) {
212
262
  const previous = this.element.getAttribute("data-intersecting");
213
263
  this.element.setAttribute("data-intersecting", intersecting ? "true" : "false");
@@ -220,57 +270,48 @@ var IntersectionController = class extends Controller {
220
270
  });
221
271
  }
222
272
  }
223
- /**
224
- * Which edge the element left across, for the `exit` detail. A non-zero
225
- * `threshold` withdraws visibility while the element still overlaps the root,
226
- * so the leaving rect can straddle the start edge — the direction is the
227
- * element's own top against that edge, not whether it has cleared the root
228
- * entirely (that is what `passed` reports). An element with no layout box
229
- * (`display: none`, a collapsed `<details>`) is reported with an empty rect
230
- * that carries no position at all, so it is deliberately neither direction
231
- * and takes the "still ahead" reading.
232
- */
233
273
  #leftViaStartEdge(entry) {
234
274
  const rect = entry.boundingClientRect;
235
275
  if (rect.width === 0 && rect.height === 0) return false;
236
276
  return rect.top < (entry.rootBounds?.top ?? 0);
237
277
  }
238
- /**
239
- * Reflects the "scrolled past" state onto `data-passed` and fires `passed` on
240
- * transitions — the line sticky headers and reading progress key off. Like
241
- * `enter`, an initial `passed=true` (page restored mid-scroll) fires; the
242
- * initial `false` is established silently.
243
- */
244
278
  #syncPassed(passed) {
245
279
  const previous = this.element.getAttribute("data-passed");
246
280
  this.element.setAttribute("data-passed", passed ? "true" : "false");
247
281
  const changed = previous === null ? passed : previous === "true" !== passed;
248
282
  if (changed) this.dispatch("passed", { detail: { passed } });
249
283
  }
250
- /** The configured `threshold`, clamped to the 0..1 the observer accepts. */
251
284
  #clampedThreshold() {
252
- return Math.min(1, Math.max(0, this.thresholdValue));
285
+ return Math.min(1, Math.max(0, this.#safeThreshold));
253
286
  }
254
- /**
255
- * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly
256
- * spaced steps when fine-grained `change` ratios are wanted (progress bars).
257
- *
258
- * 0 is always observed. An observer notifies only at the lines it was given,
259
- * so a non-zero `threshold` on its own delivers its last callback while the
260
- * element is still partly visible: the element leaving for good would never be
261
- * reported, freezing the ratio and `data-passed` mid-departure.
262
- */
263
287
  #thresholds() {
264
288
  const thresholds = /* @__PURE__ */ new Set([0, this.#clampedThreshold()]);
265
- if (this.ratioStepsValue > 0) {
266
- for (let i = 0; i <= this.ratioStepsValue; i += 1) {
267
- thresholds.add(i / this.ratioStepsValue);
289
+ if (this.#safeRatioSteps > 0) {
290
+ for (let i = 0; i <= this.#safeRatioSteps; i += 1) {
291
+ thresholds.add(i / this.#safeRatioSteps);
268
292
  }
269
293
  }
270
294
  return [...thresholds].sort((a, b) => a - b);
271
295
  }
296
+ get #safeThreshold() {
297
+ return this.#numbers.read(
298
+ this,
299
+ "threshold",
300
+ this.thresholdValue,
301
+ _IntersectionController.values.threshold.default,
302
+ _IntersectionController.valueConstraints.threshold
303
+ );
304
+ }
305
+ get #safeRatioSteps() {
306
+ return this.#numbers.read(
307
+ this,
308
+ "ratioSteps",
309
+ this.ratioStepsValue,
310
+ _IntersectionController.values.ratioSteps.default,
311
+ _IntersectionController.valueConstraints.ratioSteps
312
+ );
313
+ }
272
314
  };
273
315
 
274
316
  export { IntersectionController };
275
- //# sourceMappingURL=intersection_controller.js.map
276
317
  //# sourceMappingURL=intersection_controller.js.map