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.
- package/CHANGELOG.md +31 -0
- package/README.md +8 -1
- package/dist/cable/index.d.ts +79 -23
- package/dist/cable/index.js +363 -436
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +4 -2
- package/dist/controllers/accordion_controller.js +166 -55
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +12 -2
- package/dist/controllers/alert_dialog_controller.js +862 -221
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.d.ts +35 -5
- package/dist/controllers/announcer_controller.js +135 -198
- package/dist/controllers/announcer_controller.js.map +1 -1
- package/dist/controllers/aspect_ratio_controller.js +0 -10
- package/dist/controllers/aspect_ratio_controller.js.map +1 -1
- package/dist/controllers/auto_submit_controller.d.ts +30 -6
- package/dist/controllers/auto_submit_controller.js +271 -127
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/avatar_controller.js +171 -105
- package/dist/controllers/avatar_controller.js.map +1 -1
- package/dist/controllers/breadcrumb_controller.js +0 -130
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/bulk_select_controller.d.ts +18 -0
- package/dist/controllers/bulk_select_controller.js +262 -52
- package/dist/controllers/bulk_select_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +24 -5
- package/dist/controllers/calendar_controller.js +175 -209
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +34 -7
- package/dist/controllers/carousel_controller.js +461 -399
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.d.ts +17 -1
- package/dist/controllers/character_counter_controller.js +246 -156
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/checkbox_controller.d.ts +7 -2
- package/dist/controllers/checkbox_controller.js +32 -74
- package/dist/controllers/checkbox_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +16 -5
- package/dist/controllers/clipboard_controller.js +168 -147
- package/dist/controllers/clipboard_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.d.ts +16 -4
- package/dist/controllers/collapsible_controller.js +301 -120
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +18 -6
- package/dist/controllers/color_picker_controller.js +149 -139
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +23 -5
- package/dist/controllers/combobox_controller.js +360 -133
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +45 -14
- package/dist/controllers/command_palette_controller.js +970 -358
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/conditional_fields_controller.d.ts +15 -2
- package/dist/controllers/conditional_fields_controller.js +166 -124
- package/dist/controllers/conditional_fields_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.d.ts +49 -12
- package/dist/controllers/confirm_controller.js +939 -248
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +31 -4
- package/dist/controllers/context_menu_controller.js +256 -128
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.d.ts +13 -1
- package/dist/controllers/count_up_controller.js +73 -21
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +45 -1
- package/dist/controllers/countdown_controller.js +183 -148
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +21 -2
- package/dist/controllers/currency_input_controller.js +134 -158
- package/dist/controllers/currency_input_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +15 -4
- package/dist/controllers/data_grid_controller.js +114 -186
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +30 -5
- package/dist/controllers/date_range_picker_controller.js +288 -206
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.d.ts +13 -1
- package/dist/controllers/dialog_controller.js +858 -223
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.d.ts +24 -7
- package/dist/controllers/direct_upload_controller.js +73 -153
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/dirty_form_controller.js +14 -48
- package/dist/controllers/dirty_form_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +19 -0
- package/dist/controllers/dismissible_controller.js +175 -21
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.d.ts +39 -11
- package/dist/controllers/drawer_controller.js +933 -341
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +31 -1
- package/dist/controllers/dropdown_controller.js +312 -82
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.d.ts +12 -1
- package/dist/controllers/editable_controller.js +158 -68
- package/dist/controllers/editable_controller.js.map +1 -1
- package/dist/controllers/empty_state_controller.js +67 -53
- package/dist/controllers/empty_state_controller.js.map +1 -1
- package/dist/controllers/file_dropzone_controller.d.ts +60 -18
- package/dist/controllers/file_dropzone_controller.js +311 -207
- package/dist/controllers/file_dropzone_controller.js.map +1 -1
- package/dist/controllers/filter_controller.js +67 -39
- package/dist/controllers/filter_controller.js.map +1 -1
- package/dist/controllers/flash_controller.d.ts +31 -8
- package/dist/controllers/flash_controller.js +172 -314
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +12 -3
- package/dist/controllers/focus_controller.js +654 -240
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js +147 -132
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +13 -86
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.d.ts +41 -9
- package/dist/controllers/frame_loading_controller.js +264 -213
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.d.ts +22 -2
- package/dist/controllers/highlight_controller.js +107 -75
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +56 -3
- package/dist/controllers/hover_card_controller.js +309 -118
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/idle_controller.d.ts +42 -1
- package/dist/controllers/idle_controller.js +267 -101
- package/dist/controllers/idle_controller.js.map +1 -1
- package/dist/controllers/input_mask_controller.js +71 -72
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.d.ts +32 -13
- package/dist/controllers/intersection_controller.js +147 -106
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.d.ts +16 -0
- package/dist/controllers/lazy_frame_controller.js +9 -65
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +34 -2
- package/dist/controllers/listbox_controller.js +306 -204
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.d.ts +1 -1
- package/dist/controllers/local_time_controller.js +54 -64
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +11 -3
- package/dist/controllers/masonry_controller.js +70 -93
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/menu_controller.d.ts +32 -7
- package/dist/controllers/menu_controller.js +214 -156
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +4 -4
- package/dist/controllers/menubar_controller.js +170 -329
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/meter_controller.d.ts +20 -0
- package/dist/controllers/meter_controller.js +151 -67
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +32 -4
- package/dist/controllers/multi_select_controller.js +593 -358
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.d.ts +8 -1
- package/dist/controllers/navigation_menu_controller.js +124 -232
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.d.ts +10 -0
- package/dist/controllers/nested_form_controller.js +352 -182
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.d.ts +23 -0
- package/dist/controllers/network_status_controller.js +242 -54
- package/dist/controllers/network_status_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +26 -2
- package/dist/controllers/number_input_controller.js +320 -263
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/optimistic_controller.js +16 -76
- package/dist/controllers/optimistic_controller.js.map +1 -1
- package/dist/controllers/otp_controller.d.ts +20 -4
- package/dist/controllers/otp_controller.js +198 -193
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +5 -0
- package/dist/controllers/overflow_indicator_controller.js +116 -113
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +48 -11
- package/dist/controllers/overflow_menu_controller.js +343 -283
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +35 -6
- package/dist/controllers/pagination_controller.js +295 -119
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +45 -8
- package/dist/controllers/password_reveal_controller.js +389 -121
- package/dist/controllers/password_reveal_controller.js.map +1 -1
- package/dist/controllers/password_strength_controller.d.ts +14 -5
- package/dist/controllers/password_strength_controller.js +99 -153
- package/dist/controllers/password_strength_controller.js.map +1 -1
- package/dist/controllers/persist_controller.d.ts +11 -4
- package/dist/controllers/persist_controller.js +237 -103
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +19 -7
- package/dist/controllers/pointer_drag_controller.js +185 -165
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +38 -2
- package/dist/controllers/popover_controller.js +236 -97
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +44 -4
- package/dist/controllers/portal_controller.js +209 -76
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/preview_guard_controller.d.ts +10 -4
- package/dist/controllers/preview_guard_controller.js +232 -116
- package/dist/controllers/preview_guard_controller.js.map +1 -1
- package/dist/controllers/progress_controller.d.ts +11 -0
- package/dist/controllers/progress_controller.js +125 -56
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.d.ts +14 -5
- package/dist/controllers/radio_group_controller.js +117 -137
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +43 -3
- package/dist/controllers/range_slider_controller.js +192 -105
- package/dist/controllers/range_slider_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +20 -4
- package/dist/controllers/rating_controller.js +418 -207
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +2 -0
- package/dist/controllers/read_more_controller.js +134 -63
- package/dist/controllers/read_more_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.d.ts +1 -1
- package/dist/controllers/reading_progress_controller.js +147 -135
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.d.ts +18 -4
- package/dist/controllers/relative_time_controller.js +145 -110
- package/dist/controllers/relative_time_controller.js.map +1 -1
- package/dist/controllers/reset_on_restore_controller.d.ts +85 -0
- package/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
- package/dist/controllers/reset_on_restore_controller.js.map +1 -0
- package/dist/controllers/resizable_controller.d.ts +28 -2
- package/dist/controllers/resizable_controller.js +255 -139
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.js +39 -84
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.d.ts +7 -3
- package/dist/controllers/scroll_area_controller.js +428 -225
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.js +0 -53
- package/dist/controllers/scroll_restore_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.d.ts +28 -12
- package/dist/controllers/scroll_visibility_controller.js +395 -145
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +6 -1
- package/dist/controllers/scrollspy_controller.js +181 -242
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +19 -4
- package/dist/controllers/separator_controller.js +267 -140
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +64 -7
- package/dist/controllers/sidebar_controller.js +1023 -386
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.d.ts +23 -0
- package/dist/controllers/skeleton_controller.js +248 -97
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +27 -1
- package/dist/controllers/slider_controller.js +159 -79
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.d.ts +21 -3
- package/dist/controllers/smart_sticky_header_controller.js +162 -63
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.js +0 -112
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.d.ts +39 -4
- package/dist/controllers/spinner_controller.js +277 -169
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +5 -0
- package/dist/controllers/step_indicator_controller.js +99 -46
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +12 -3
- package/dist/controllers/stepper_controller.js +114 -73
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +6 -0
- package/dist/controllers/stick_to_bottom_controller.js +262 -138
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +11 -0
- package/dist/controllers/sticky_observer_controller.js +214 -49
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +23 -8
- package/dist/controllers/submit_once_controller.js +294 -205
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.d.ts +12 -1
- package/dist/controllers/switch_controller.js +33 -60
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +2 -2
- package/dist/controllers/tabs_controller.js +35 -29
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +12 -0
- package/dist/controllers/tags_input_controller.js +136 -145
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.d.ts +10 -0
- package/dist/controllers/textarea_autosize_controller.js +71 -58
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +21 -9
- package/dist/controllers/theme_controller.js +130 -112
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +18 -1
- package/dist/controllers/time_picker_controller.js +146 -80
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +36 -11
- package/dist/controllers/toast_controller.js +186 -297
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.d.ts +12 -3
- package/dist/controllers/toggle_group_controller.js +95 -136
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.js +68 -98
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +45 -3
- package/dist/controllers/tooltip_controller.js +158 -124
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +22 -4
- package/dist/controllers/transition_controller.js +123 -119
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +21 -11
- package/dist/controllers/tree_view_controller.js +150 -345
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +6370 -9346
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +23 -3
- package/dist/inspector/cli.js +92 -37
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +92 -56
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +8 -8
- package/dist/inspector/manifest.json +787 -100
- package/dist/positioning/index.d.ts +8 -0
- package/dist/positioning/index.js +148 -47
- package/dist/positioning/index.js.map +1 -1
- package/package.json +6 -6
- package/dist/controllers/reset_before_cache_controller.d.ts +0 -76
- package/dist/controllers/reset_before_cache_controller.js.map +0 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
this.#
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
248
|
+
root,
|
|
177
249
|
rootMargin: this.rootMarginValue,
|
|
178
|
-
threshold:
|
|
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
|
|
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
|
|
266
|
-
for (let i = 0; i <= this
|
|
267
|
-
thresholds.add(i / this
|
|
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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/declared_value.ts","../../src/utils/intersection_watcher.ts","../../src/controllers/intersection_controller.ts"],"names":[],"mappings":"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","file":"intersection_controller.js","sourcesContent":["/**\n * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n}\n","/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector (degrading to the viewport rather than failing), observer\n * creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like `RovingTabindex` and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n\nimport { parseDeclared } from \"./declared_value\";\n\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\n/**\n * Resolves an observation root from a selector. Every reading of \"no root\" ends\n * at the same place — absent, matching nothing, or not parsing at all (a typo in\n * a data attribute) — so the observation falls back to the viewport instead of\n * leaving the caller inert with no state hooks published at all.\n */\nfunction queryRoot(selector: string | undefined): Element | null {\n if (!selector) return null;\n return parseDeclared(selector, (raw) => document.querySelector(raw), null);\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /**\n * Selector for the observation root; empty/omitted = viewport. A selector\n * that matches nothing or does not parse also means the viewport.\n */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults. A\n * `rootSelector` that does not parse resolves to the viewport (see\n * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the\n * call.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root = \"root\" in options ? (options.root ?? null) : queryRoot(options.rootSelector);\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher, isBeforeRootStart } from \"../utils/intersection_watcher\";\n\n/** Name of the CSS custom property exposing the visible ratio (0..1). */\nconst RATIO_PROPERTY = \"--stimeo--intersection-ratio\";\n\n/**\n * Tolerance for the visibility test. Real observers can report a ratio a hair\n * below the configured threshold at that threshold's own crossing callback\n * (fractional device pixels / zoom), most visibly at threshold 1 where \"fully\n * visible\" may arrive as 0.99x — a strict `>=` would then never see it.\n */\nconst RATIO_EPSILON = 0.01;\n\n/**\n * Headless **intersection primitive**: a thin declarative wrapper over\n * {@link IntersectionObserver} that turns viewport visibility into events and\n * state hooks. It is the scroll-triggered building block for\n * scroll-driven behavior — loading more on approach, \"animate when visible\"\n * (compose it with `stimeo--count-up`), progress and sticky-header work — so a\n * consumer does not write its own observer. No APG widget — a pure\n * state-detection utility. Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--intersection`):\n * <div data-controller=\"stimeo--intersection\"\n * data-stimeo--intersection-root-margin-value=\"200px\"\n * data-action=\"stimeo--intersection:enter->feed#loadNextPage\"></div>\n *\n * The controller observes its own element. `enter` fires when the element\n * becomes visible (intersection ratio reaches `threshold`; detail `{ ratio }`),\n * `exit` when it leaves (detail `{ ratio, position }`, where `position` is the\n * edge it left across — `\"before\"` = upward past the root's start edge,\n * `\"after\"` = downward, still ahead), `change` on every observed update\n * (detail `{ intersecting, ratio }` — set `ratioSteps` for fine-grained ratio\n * reporting), and `passed` when the element fully crosses the root's start edge\n * in either direction (detail `{ passed }` — the sticky/progress line). The\n * visibility is mirrored as `data-intersecting`/`data-passed` and the ratio as\n * the `--stimeo--intersection-ratio` custom property for consumer CSS.\n *\n * @remarks\n * Behavior only — what visibility *means* (load a page, start an animation,\n * pin a header) belongs to the consumer via `data-action`/CSS. `connect()` is\n * idempotent: the previous state is read back from `data-intersecting`/\n * `data-passed`, so a Turbo cache restore does not re-fire `enter` for an\n * element that was already visible (and with `once`, an element whose enter\n * already fired is not observed again). `threshold` is re-read when Turbo morphs\n * the attribute in place, and a `rootSelector` that does not parse observes the\n * viewport rather than leaving the element unobserved. Without\n * `IntersectionObserver` (very old browsers) the controller stays inert —\n * consumers keep whatever no-JS fallback their markup provides. The observer is\n * disconnected on `disconnect()` (Turbo navigation included).\n */\nexport class IntersectionController extends Controller<HTMLElement> {\n static override values = {\n threshold: { type: Number, default: 0 },\n ratioSteps: { type: Number, default: 0 },\n rootMargin: { type: String, default: \"0px\" },\n rootSelector: { type: String, default: \"\" },\n once: { type: Boolean, default: false },\n };\n static actions = [\"refresh\"] as const;\n static events = [\"enter\", \"exit\", \"change\", \"passed\"] as const;\n\n declare thresholdValue: number;\n declare ratioStepsValue: number;\n declare rootMarginValue: string;\n declare rootSelectorValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Threshold actually installed in the live observer (0 after option fallback). */\n #effectiveThreshold = 0;\n /** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */\n #generation = 0;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n // A single callback can batch several transitions for the same target\n // (delivery lagging behind a fast scroll), so process every entry in\n // order — collapsing to the last one alone would drop an enter→exit pair\n // and, under `once`, lose the one-shot enter entirely. If a handler calls\n // `refresh()` mid-batch (enter → append content → re-arm), the remaining\n // entries describe a state `refresh` just reset — replaying them would\n // re-fire `enter` for the same visibility episode — so the generation\n // bump abandons them and the re-observation delivers the fresh state\n // (`once` stopping the watcher mid-batch is caught by the active check).\n const generation = this.#generation;\n for (const entry of entries) {\n if (!this.#watcher.active || this.#generation !== generation) return;\n\n const ratio = entry.intersectionRatio;\n // `isIntersecting` is geometric (\"any overlap\"), so a non-zero `threshold`\n // (\"counts as visible at ≥N%\") must be applied to the ratio ourselves —\n // against the same 0..1-clamped value the observer was configured with, or\n // a `threshold` above 1 would make `intersecting` unreachable while the\n // observer still fires at ratio 1. The epsilon absorbs subpixel rounding\n // (see RATIO_EPSILON); keeping the geometric `isIntersecting` conjunct\n // stops it from underflowing a tiny threshold into \"always visible\".\n // A constructor fallback omits the configured threshold, so the observer\n // can only notify at its effective default line (0). Applying the authored\n // line here would wait for a callback that the fallback observer never\n // schedules after an initially intersecting entry.\n const threshold = this.#effectiveThreshold;\n const intersecting =\n threshold > 0\n ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON\n : entry.isIntersecting;\n\n this.element.style.setProperty(RATIO_PROPERTY, String(ratio));\n this.dispatch(\"change\", { detail: { intersecting, ratio } });\n this.#syncIntersecting(intersecting, ratio, entry);\n this.#syncPassed(!intersecting && isBeforeRootStart(entry));\n }\n }\n\n override connect(): void {\n // A cache restore may bring back an element whose one-shot enter already\n // fired; honor it instead of re-observing (mirrors `data-lazy-loaded`).\n if (this.onceValue && this.element.getAttribute(\"data-intersecting\") === \"true\") return;\n this.#observe();\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n }\n\n /**\n * Re-reads the visibility line and rebuilds the observer. Turbo 8 morphing\n * rewrites the attribute in place without a reconnect, and the line is what\n * the intersection callback compares every ratio against, so a value frozen at\n * connect time would decide `data-intersecting` wrongly for the rest of the\n * page's life. Nothing to rebuild before the first `connect()`; after a spent\n * one-shot the watcher is deliberately stopped, and re-observing would deliver\n * the current state and fire `enter` a second time.\n */\n thresholdValueChanged(): void {\n if (this.#watcher.active) this.#observe();\n }\n\n /** (Re)installs the observer from the current Values. */\n #observe(): void {\n this.#effectiveThreshold = this.#clampedThreshold();\n this.#watcher.start(this.element, {\n rootSelector: this.rootSelectorValue,\n rootMargin: this.rootMarginValue,\n threshold: this.#thresholds(),\n });\n if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;\n }\n\n /**\n * Re-delivers the current intersection state as a fresh transition. Bound via\n * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).\n *\n * `IntersectionObserver` only reports state *changes*, so a sentinel that\n * stays visible while content is appended below it never fires `enter` again\n * and a hand-rolled infinite scroll stalls. `observe()` always delivers the\n * current state, and clearing the recorded `data-intersecting`/`data-passed`\n * makes that delivery count as a transition — a still-visible sentinel\n * re-fires `enter`. No-op once the observer is gone (`once` fired, no\n * `IntersectionObserver` support, or after `disconnect()`).\n */\n refresh(): void {\n if (!this.#watcher.active) return;\n this.#generation += 1;\n this.element.removeAttribute(\"data-intersecting\");\n this.element.removeAttribute(\"data-passed\");\n this.#watcher.rearm(this.element);\n }\n\n /**\n * Reflects the visibility onto `data-intersecting` and fires `enter`/`exit`\n * on transitions. The previous state is the DOM attribute (source of truth),\n * so the observer's initial callback fires `enter` for an element that starts\n * visible but stays silent after a cache restore that already recorded it.\n * An initial not-visible state is established silently (no `exit`).\n *\n * @stimeoRuntimeOnly `once` decides whether this enter spends the watcher's one shot; the hook it\n * writes follows the entry.\n */\n #syncIntersecting(intersecting: boolean, ratio: number, entry: IntersectionObserverEntry): void {\n const previous = this.element.getAttribute(\"data-intersecting\");\n this.element.setAttribute(\"data-intersecting\", intersecting ? \"true\" : \"false\");\n\n if (intersecting && previous !== \"true\") {\n // One-shot mode: the shot is spent at this transition, so stop observing\n // before the event. A handler that re-arms (the `enter` -> append ->\n // `refresh()` reflex) then finds an inactive watcher and leaves the hooks\n // in their final state — `data-intersecting=\"true\"` marks it for reconnects.\n if (this.onceValue) this.#watcher.stop();\n this.dispatch(\"enter\", { detail: { ratio } });\n } else if (!intersecting && previous === \"true\") {\n this.dispatch(\"exit\", {\n detail: { ratio, position: this.#leftViaStartEdge(entry) ? \"before\" : \"after\" },\n });\n }\n }\n\n /**\n * Which edge the element left across, for the `exit` detail. A non-zero\n * `threshold` withdraws visibility while the element still overlaps the root,\n * so the leaving rect can straddle the start edge — the direction is the\n * element's own top against that edge, not whether it has cleared the root\n * entirely (that is what `passed` reports). An element with no layout box\n * (`display: none`, a collapsed `<details>`) is reported with an empty rect\n * that carries no position at all, so it is deliberately neither direction\n * and takes the \"still ahead\" reading.\n */\n #leftViaStartEdge(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n return rect.top < (entry.rootBounds?.top ?? 0);\n }\n\n /**\n * Reflects the \"scrolled past\" state onto `data-passed` and fires `passed` on\n * transitions — the line sticky headers and reading progress key off. Like\n * `enter`, an initial `passed=true` (page restored mid-scroll) fires; the\n * initial `false` is established silently.\n */\n #syncPassed(passed: boolean): void {\n const previous = this.element.getAttribute(\"data-passed\");\n this.element.setAttribute(\"data-passed\", passed ? \"true\" : \"false\");\n const changed = previous === null ? passed : (previous === \"true\") !== passed;\n if (changed) this.dispatch(\"passed\", { detail: { passed } });\n }\n\n /** The configured `threshold`, clamped to the 0..1 the observer accepts. */\n #clampedThreshold(): number {\n return Math.min(1, Math.max(0, this.thresholdValue));\n }\n\n /**\n * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly\n * spaced steps when fine-grained `change` ratios are wanted (progress bars).\n *\n * 0 is always observed. An observer notifies only at the lines it was given,\n * so a non-zero `threshold` on its own delivers its last callback while the\n * element is still partly visible: the element leaving for good would never be\n * reported, freezing the ratio and `data-passed` mid-departure.\n */\n #thresholds(): number[] {\n const thresholds = new Set<number>([0, this.#clampedThreshold()]);\n if (this.ratioStepsValue > 0) {\n // i counts up to ratioSteps, so i/ratioSteps is inherently 0..1.\n for (let i = 0; i <= this.ratioStepsValue; i += 1) {\n thresholds.add(i / this.ratioStepsValue);\n }\n }\n return [...thresholds].sort((a, b) => a - b);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/declared_value.ts","../../src/utils/intersection_watcher.ts","../../src/utils/microtask_coalescer.ts","../../src/utils/number_bounds.ts","../../src/utils/coerce.ts","../../src/utils/number_value.ts","../../src/controllers/intersection_controller.ts"],"names":[],"mappings":"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","file":"intersection_controller.js","sourcesContent":["/**\n * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n}\n","/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector (degrading to the viewport rather than failing), observer\n * creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like `RovingTabindex` and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n\nimport { parseDeclared } from \"./declared_value\";\n\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\n/**\n * Resolves an observation root from a selector. Every reading of \"no root\" ends\n * at the same place — absent, matching nothing, or not parsing at all (a typo in\n * a data attribute) — so the observation falls back to the viewport instead of\n * leaving the caller inert with no state hooks published at all.\n */\nfunction queryRoot(selector: string | undefined): Element | null {\n if (!selector) return null;\n return parseDeclared(selector, (raw) => document.querySelector(raw), null);\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /**\n * Selector for the observation root; empty/omitted = viewport. A selector\n * that matches nothing or does not parse also means the viewport.\n */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults. A\n * `rootSelector` that does not parse resolves to the viewport (see\n * {@link IntersectionWatchOptions.rootSelector}), so a typo never fails the\n * call.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root = \"root\" in options ? (options.root ?? null) : queryRoot(options.rootSelector);\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","import { MAX_TIMER_DELAY_MS } from \"./timer_bounds\";\n\n/** Semantic constraints on an already decoded number, independent of the DOM. */\nexport interface NumberBounds {\n /** Finite numbers are required, including when this field is omitted. */\n readonly finite?: true;\n /** Inclusive lower endpoint. */\n readonly min?: number;\n /** Inclusive upper endpoint. */\n readonly max?: number;\n /** Exclusive lower endpoint. */\n readonly exclusiveMin?: number;\n /** Require a number without a fractional part. */\n readonly integer?: boolean;\n /** Exact decoded numbers accepted by a discrete contract. */\n readonly allowedValues?: readonly number[];\n /** JSON-safe exceptions to finite-number checking for unbounded endpoints. */\n readonly allowInfinity?: \"negative\" | \"positive\" | \"both\";\n}\n\n/** One constraint for each Number Value, including shorthand declarations. */\nexport type NumberValueConstraints<Values> = {\n readonly [Key in keyof Values as Values[Key] extends\n | NumberConstructor\n | { type: NumberConstructor }\n ? Key\n : never]: NumberBounds;\n};\n\n/** Reusable semantic domains; fractional inputs remain fractional unless excluded. */\nexport const NUMBER_BOUNDS = {\n finite: { finite: true },\n nonNegative: { finite: true, min: 0 },\n positive: { finite: true, exclusiveMin: 0 },\n nonNegativeInteger: { finite: true, min: 0, integer: true },\n positiveInteger: { finite: true, exclusiveMin: 0, integer: true },\n lowerBound: { finite: true, allowInfinity: \"negative\" },\n upperBound: { finite: true, allowInfinity: \"positive\" },\n timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS },\n positiveTimer: { finite: true, exclusiveMin: 0, max: MAX_TIMER_DELAY_MS },\n} as const satisfies Record<string, NumberBounds>;\n\n/**\n * Checks all declared bounds without rounding, clamping, or coercing the input.\n * Infinity exceptions relax only finite checking; every other bound still applies.\n */\nexport function matchesNumberBounds(value: number, bounds: NumberBounds): boolean {\n if (!Number.isFinite(value)) {\n const direction = value === Infinity ? \"positive\" : value === -Infinity ? \"negative\" : null;\n if (direction === null) return false;\n if (bounds.allowInfinity !== \"both\" && bounds.allowInfinity !== direction) return false;\n }\n if (bounds.min !== undefined && value < bounds.min) return false;\n if (bounds.max !== undefined && value > bounds.max) return false;\n if (bounds.exclusiveMin !== undefined && value <= bounds.exclusiveMin) return false;\n if (bounds.integer && !Number.isInteger(value)) return false;\n if (bounds.allowedValues !== undefined && !bounds.allowedValues.includes(value)) return false;\n return true;\n}\n\n/** Decodes a Number Value literal; action params use their own JSON decoder. */\nexport function decodeNumberValue(raw: string): number {\n return Number(raw.replace(/_/g, \"\"));\n}\n","/**\n * Numeric coercion for controllers that accept a number through a Value, an action\n * param, or an event detail.\n *\n * Stimulus decodes action params as JSON when possible and leaves other text\n * untouched. CustomEvent detail can also supply a number or numeric string.\n * The reader accepts those two types while rejecting blank and non-finite input.\n */\n\nimport { matchesNumberBounds, type NumberBounds } from \"./number_bounds\";\n\n/**\n * Reads a finite number or nonblank numeric string; other input returns `null`.\n * Blank strings cannot silently reset a value to zero.\n */\nexport function toFiniteNumber(raw: unknown): number | null {\n if (typeof raw !== \"number\" && typeof raw !== \"string\") return null;\n if (typeof raw === \"string\" && raw.trim().length === 0) return null;\n const value = Number(raw);\n return Number.isFinite(value) ? value : null;\n}\n\n/**\n * Returns an accepted decoded Number Value or the caller's fallback unchanged.\n * The caller owns the fallback contract; this reader does not parse or write a Value.\n */\nexport function readNumber(raw: number, fallback: number, bounds: NumberBounds): number {\n return matchesNumberBounds(raw, bounds) ? raw : fallback;\n}\n","import { readNumber } from \"./coerce\";\nimport { matchesNumberBounds, type NumberBounds } from \"./number_bounds\";\n\n/** The instance identity carried by a Number Value read. */\nexport interface NumberValueOwner {\n readonly identifier: string;\n readonly element: Element;\n}\n\n/** Reads current declarations at the point of use without rewriting their attributes. */\nexport class NumberValueReader {\n /** The last rejected literal per Value; no history grows for an unchanged instance. */\n readonly #lastRejected = new Map<string, string>();\n\n /** Resolves the declared number using its own fallback and shared class contract. */\n read(\n owner: NumberValueOwner,\n name: string,\n raw: number,\n fallback: number,\n bounds: NumberBounds,\n ): number {\n const resolved = readNumber(raw, fallback, bounds);\n if (matchesNumberBounds(raw, bounds)) {\n this.#lastRejected.delete(name);\n return resolved;\n }\n const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;\n const literal = owner.element.getAttribute(attribute);\n if (literal === null) {\n this.#lastRejected.delete(name);\n return resolved;\n }\n if (this.#lastRejected.get(name) !== literal) {\n this.#lastRejected.set(name, literal);\n console.warn(\n `Stimeo UI: \"${owner.identifier}\" has an invalid number Value \"${name}\" declaration ${JSON.stringify(literal)}; using ${fallback}.`,\n );\n }\n return resolved;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { validSelector } from \"../utils/declared_value\";\nimport { IntersectionWatcher, isBeforeRootStart } from \"../utils/intersection_watcher\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\nimport { NUMBER_BOUNDS, type NumberValueConstraints } from \"../utils/number_bounds\";\nimport { NumberValueReader } from \"../utils/number_value\";\n\n/** Name of the CSS custom property exposing the visible ratio (0..1). */\nconst RATIO_PROPERTY = \"--stimeo--intersection-ratio\";\n\n/**\n * Tolerance for the visibility test. Real observers can report a ratio a hair\n * below the configured threshold at that threshold's own crossing callback\n * (fractional device pixels / zoom), most visibly at threshold 1 where \"fully\n * visible\" may arrive as 0.99x — a strict `>=` would then never see it.\n */\nconst RATIO_EPSILON = 0.01;\n\n/**\n * Headless **intersection primitive**: a thin declarative wrapper over\n * {@link IntersectionObserver} that turns viewport visibility into events and\n * state hooks. It is the scroll-triggered building block for\n * scroll-driven behavior — loading more on approach, \"animate when visible\"\n * (compose it with `stimeo--count-up`), progress and sticky-header work — so a\n * consumer does not write its own observer. No APG widget — a pure\n * state-detection utility. Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--intersection`):\n * <div data-controller=\"stimeo--intersection\"\n * data-stimeo--intersection-root-margin-value=\"200px\"\n * data-action=\"stimeo--intersection:enter->feed#loadNextPage\"></div>\n *\n * The controller observes its own element. `enter` fires when the element\n * becomes visible (intersection ratio reaches `threshold`; detail `{ ratio }`),\n * `exit` when it leaves (detail `{ ratio, position }`, where `position` is the\n * edge it left across — `\"before\"` = upward past the root's start edge,\n * `\"after\"` = downward, still ahead), `change` on every observed update\n * (detail `{ intersecting, ratio }` — set `ratioSteps` for fine-grained ratio\n * reporting), and `passed` when the element fully crosses the root's start edge\n * in either direction (detail `{ passed }` — the sticky/progress line). The\n * visibility is mirrored as `data-intersecting`/`data-passed` and the ratio as\n * the `--stimeo--intersection-ratio` custom property for consumer CSS.\n *\n * @remarks\n * Behavior only — what visibility *means* (load a page, start an animation,\n * pin a header) belongs to the consumer via `data-action`/CSS. `connect()` is\n * idempotent: the previous state is read back from `data-intersecting`/\n * `data-passed`, so a Turbo cache restore does not re-fire `enter` for an\n * element that was already visible (and with `once`, an element whose enter\n * already fired is not observed again). Every Value follows a runtime change —\n * a Turbo morph, a Stream, an author script: the observer is rebuilt once per\n * batch from the current declaration, and only when the root node, `rootMargin`\n * or the lines it observes differ from the live one. `once` follows the same\n * way: turned off after its enter, the element is observed again and the first\n * callback is measured against the recorded hooks, exactly as on a reconnect;\n * turned on once an enter is recorded, observing stops. A `rootSelector` that\n * does not parse observes the viewport rather than leaving the element\n * unobserved. Without `IntersectionObserver` (very old browsers) the controller\n * stays inert — consumers keep whatever no-JS fallback their markup provides.\n * The observer is disconnected on `disconnect()` (Turbo navigation included).\n */\nexport class IntersectionController extends Controller<HTMLElement> {\n /** Numeric read boundaries share one reader for this controller instance. */\n readonly #numbers = new NumberValueReader();\n\n static override values = {\n threshold: { type: Number, default: 0 },\n ratioSteps: { type: Number, default: 0 },\n rootMargin: { type: String, default: \"0px\" },\n rootSelector: { type: String, default: \"\" },\n once: { type: Boolean, default: false },\n };\n\n static valueConstraints = {\n threshold: NUMBER_BOUNDS.finite,\n ratioSteps: { finite: true, min: 0, max: 1000 },\n } satisfies NumberValueConstraints<typeof IntersectionController.values>;\n static actions = [\"refresh\"] as const;\n static events = [\"enter\", \"exit\", \"change\", \"passed\"] as const;\n\n declare thresholdValue: number;\n declare ratioStepsValue: number;\n declare rootMarginValue: string;\n declare rootSelectorValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, active guard, re-arm). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /**\n * One rebuild for every Value a batch changes, inside the connected window\n * only: Stimulus delivers Value callbacks ahead of `connect()`, which builds\n * the observer itself, and a pass queued before `disconnect()` is dropped.\n */\n readonly #rebuild = new MicrotaskCoalescer(() => this.#sync());\n /** Validated `rootSelector`; an unparsable declaration reads as the viewport. */\n #rootSelector = \"\";\n /** The root node the live observer was built on. */\n #builtRoot: Element | null = null;\n /** `rootMargin` and the observed lines the live observer was built from. */\n #builtOptions = \"\";\n /** Threshold actually installed in the live observer (0 after option fallback). */\n #effectiveThreshold = 0;\n /** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */\n #generation = 0;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n // A single callback can batch several transitions for the same target\n // (delivery lagging behind a fast scroll), so process every entry in\n // order — collapsing to the last one alone would drop an enter→exit pair\n // and, under `once`, lose the one-shot enter entirely. If a handler calls\n // `refresh()` mid-batch (enter → append content → re-arm), the remaining\n // entries describe a state `refresh` just reset — replaying them would\n // re-fire `enter` for the same visibility episode — so the generation\n // bump abandons them and the re-observation delivers the fresh state\n // (`once` stopping the watcher mid-batch is caught by the active check).\n const generation = this.#generation;\n for (const entry of entries) {\n if (!this.#watcher.active || this.#generation !== generation) return;\n\n const ratio = entry.intersectionRatio;\n // `isIntersecting` is geometric (\"any overlap\"), so a non-zero `threshold`\n // (\"counts as visible at ≥N%\") must be applied to the ratio ourselves —\n // against the same 0..1-clamped value the observer was configured with, or\n // a `threshold` above 1 would make `intersecting` unreachable while the\n // observer still fires at ratio 1. The epsilon absorbs subpixel rounding\n // (see RATIO_EPSILON); keeping the geometric `isIntersecting` conjunct\n // stops it from underflowing a tiny threshold into \"always visible\".\n // A constructor fallback omits the configured threshold, so the observer\n // can only notify at its effective default line (0). Applying the authored\n // line here would wait for a callback that the fallback observer never\n // schedules after an initially intersecting entry.\n const threshold = this.#effectiveThreshold;\n const intersecting =\n threshold > 0\n ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON\n : entry.isIntersecting;\n\n this.element.style.setProperty(RATIO_PROPERTY, String(ratio));\n this.dispatch(\"change\", { detail: { intersecting, ratio } });\n this.#syncIntersecting(intersecting, ratio, entry);\n this.#syncPassed(!intersecting && isBeforeRootStart(entry));\n }\n }\n\n override connect(): void {\n this.#rebuild.activate();\n this.#sync();\n }\n\n override disconnect(): void {\n this.#rebuild.cancel();\n this.#watcher.stop();\n }\n\n /**\n * Follows the visibility line: the intersection callback compares every ratio\n * against it, so a line frozen at connect time would decide\n * `data-intersecting` wrongly for the rest of the page's life.\n */\n thresholdValueChanged(): void {\n this.#rebuild.schedule();\n }\n\n /** Follows the fine-grained `change` steps the observer notifies at. */\n ratioStepsValueChanged(): void {\n this.#rebuild.schedule();\n }\n\n /** Follows the margin the observer grows or shrinks its root by. */\n rootMarginValueChanged(): void {\n this.#rebuild.schedule();\n }\n\n /** Validates `rootSelector` once per change, then follows the root it names. */\n rootSelectorValueChanged(): void {\n this.#rootSelector = validSelector(this.element, this.rootSelectorValue, \"\");\n this.#rebuild.schedule();\n }\n\n /** Follows whether one recorded enter ends the observation. */\n onceValueChanged(): void {\n this.#rebuild.schedule();\n }\n\n /**\n * Brings the observer in line with the current declaration: a spent one-shot\n * observes nothing, and anything else observes with the root, margin and\n * lines declared now. A live observer already built from the same root node\n * and options is kept, since a rebuild re-delivers the current state as a\n * fresh callback.\n *\n * A spent one-shot is `once` with an enter recorded in `data-intersecting` —\n * the state a cache restore brings back too — so a declaration change and a\n * reconnect reach the same observer. Re-arming leaves the recorded hooks in\n * place: the first callback reports where the element is, and it is measured\n * against them like the first callback after a reconnect, never as a fresh\n * `enter` for an element that is still visible.\n */\n #sync(): void {\n if (this.onceValue && this.element.getAttribute(\"data-intersecting\") === \"true\") {\n this.#watcher.stop();\n return;\n }\n const root = this.#rootSelector ? document.querySelector(this.#rootSelector) : null;\n const threshold = this.#clampedThreshold();\n const thresholds = this.#thresholds();\n const options = `${this.rootMarginValue} ${threshold} ${thresholds.join(\",\")}`;\n if (this.#watcher.active && root === this.#builtRoot && options === this.#builtOptions) return;\n\n this.#builtRoot = root;\n this.#builtOptions = options;\n this.#effectiveThreshold = threshold;\n this.#watcher.start(this.element, {\n root,\n rootMargin: this.rootMarginValue,\n threshold: thresholds,\n });\n if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;\n }\n\n /**\n * Re-delivers the current intersection state as a fresh transition. Bound via\n * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).\n *\n * `IntersectionObserver` only reports state *changes*, so a sentinel that\n * stays visible while content is appended below it never fires `enter` again\n * and a hand-rolled infinite scroll stalls. `observe()` always delivers the\n * current state, and clearing the recorded `data-intersecting`/`data-passed`\n * makes that delivery count as a transition — a still-visible sentinel\n * re-fires `enter`. No-op once the observer is gone (`once` fired, no\n * `IntersectionObserver` support, or after `disconnect()`).\n */\n refresh(): void {\n if (!this.#watcher.active) return;\n this.#generation += 1;\n this.element.removeAttribute(\"data-intersecting\");\n this.element.removeAttribute(\"data-passed\");\n this.#watcher.rearm(this.element);\n }\n\n /**\n * Reflects the visibility onto `data-intersecting` and fires `enter`/`exit`\n * on transitions. The previous state is the DOM attribute (source of truth),\n * so the observer's initial callback fires `enter` for an element that starts\n * visible but stays silent after a cache restore that already recorded it.\n * An initial not-visible state is established silently (no `exit`).\n *\n * @stimeoRuntimeOnly `once` decides whether this enter spends the watcher's one shot; the hook it\n * writes follows the entry.\n */\n #syncIntersecting(intersecting: boolean, ratio: number, entry: IntersectionObserverEntry): void {\n const previous = this.element.getAttribute(\"data-intersecting\");\n this.element.setAttribute(\"data-intersecting\", intersecting ? \"true\" : \"false\");\n\n if (intersecting && previous !== \"true\") {\n // One-shot mode: the shot is spent at this transition, so stop observing\n // before the event. A handler that re-arms (the `enter` -> append ->\n // `refresh()` reflex) then finds an inactive watcher and leaves the hooks\n // in their final state — `data-intersecting=\"true\"` marks it for reconnects.\n if (this.onceValue) this.#watcher.stop();\n this.dispatch(\"enter\", { detail: { ratio } });\n } else if (!intersecting && previous === \"true\") {\n this.dispatch(\"exit\", {\n detail: { ratio, position: this.#leftViaStartEdge(entry) ? \"before\" : \"after\" },\n });\n }\n }\n\n /**\n * Which edge the element left across, for the `exit` detail. A non-zero\n * `threshold` withdraws visibility while the element still overlaps the root,\n * so the leaving rect can straddle the start edge — the direction is the\n * element's own top against that edge, not whether it has cleared the root\n * entirely (that is what `passed` reports). An element with no layout box\n * (`display: none`, a collapsed `<details>`) is reported with an empty rect\n * that carries no position at all, so it is deliberately neither direction\n * and takes the \"still ahead\" reading.\n */\n #leftViaStartEdge(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n return rect.top < (entry.rootBounds?.top ?? 0);\n }\n\n /**\n * Reflects the \"scrolled past\" state onto `data-passed` and fires `passed` on\n * transitions — the line sticky headers and reading progress key off. Like\n * `enter`, an initial `passed=true` (page restored mid-scroll) fires; the\n * initial `false` is established silently.\n */\n #syncPassed(passed: boolean): void {\n const previous = this.element.getAttribute(\"data-passed\");\n this.element.setAttribute(\"data-passed\", passed ? \"true\" : \"false\");\n const changed = previous === null ? passed : (previous === \"true\") !== passed;\n if (changed) this.dispatch(\"passed\", { detail: { passed } });\n }\n\n /** The configured `threshold`, clamped to the 0..1 the observer accepts. */\n #clampedThreshold(): number {\n return Math.min(1, Math.max(0, this.#safeThreshold));\n }\n\n /**\n * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly\n * spaced steps when fine-grained `change` ratios are wanted (progress bars).\n *\n * 0 is always observed. An observer notifies only at the lines it was given,\n * so a non-zero `threshold` on its own delivers its last callback while the\n * element is still partly visible: the element leaving for good would never be\n * reported, freezing the ratio and `data-passed` mid-departure.\n */\n #thresholds(): number[] {\n const thresholds = new Set<number>([0, this.#clampedThreshold()]);\n if (this.#safeRatioSteps > 0) {\n // i counts up to ratioSteps, so i/ratioSteps is inherently 0..1.\n for (let i = 0; i <= this.#safeRatioSteps; i += 1) {\n thresholds.add(i / this.#safeRatioSteps);\n }\n }\n return [...thresholds].sort((a, b) => a - b);\n }\n /** Current `threshold` declaration resolved against its numeric contract. */\n get #safeThreshold(): number {\n return this.#numbers.read(\n this,\n \"threshold\",\n this.thresholdValue,\n IntersectionController.values.threshold.default,\n IntersectionController.valueConstraints.threshold,\n );\n }\n\n /** Current `ratioSteps` declaration resolved against its numeric contract. */\n get #safeRatioSteps(): number {\n return this.#numbers.read(\n this,\n \"ratioSteps\",\n this.ratioStepsValue,\n IntersectionController.values.ratioSteps.default,\n IntersectionController.valueConstraints.ratioSteps,\n );\n }\n}\n"]}
|
|
@@ -30,6 +30,11 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
30
30
|
*
|
|
31
31
|
* `load` dispatches `{ url }` — always the URL the fetch actually started for.
|
|
32
32
|
*
|
|
33
|
+
* `once` follows a runtime change for a frame that has loaded: turned off, the frame is
|
|
34
|
+
* observed again and the visit begins wherever the observer first finds it, exactly as on a
|
|
35
|
+
* reconnect; turned on, observing stops. Before the first load it has nothing to change,
|
|
36
|
+
* because it only decides what that load leaves behind.
|
|
37
|
+
*
|
|
33
38
|
* @remarks
|
|
34
39
|
* Behavior only — the load itself and the frame's content are Turbo's / the server's job,
|
|
35
40
|
* and the loading UI (skeleton / `aria-busy`) belongs to `stimeo--frame-loading`. The trigger
|
|
@@ -70,6 +75,17 @@ declare class LazyFrameController extends Controller<HTMLElement> {
|
|
|
70
75
|
urlValueChanged(): void;
|
|
71
76
|
/** Rebuilds the observer when the early-load margin changes at runtime. */
|
|
72
77
|
rootMarginValueChanged(): void;
|
|
78
|
+
/**
|
|
79
|
+
* Follows `once` for a frame that has loaded, reaching the observer a reconnect
|
|
80
|
+
* would build: a loaded frame under `once` is left unobserved, and one that may
|
|
81
|
+
* re-fetch is observed with its baseline taken afresh, so the first report is
|
|
82
|
+
* where the frame is rather than a return to it.
|
|
83
|
+
*
|
|
84
|
+
* Stimulus can deliver this ahead of `connect()`, and `#loaded` may still hold
|
|
85
|
+
* the previous connection's answer then; the connected guard keeps a callback
|
|
86
|
+
* outside the connected window from building anything.
|
|
87
|
+
*/
|
|
88
|
+
onceValueChanged(): void;
|
|
73
89
|
connect(): void;
|
|
74
90
|
disconnect(): void;
|
|
75
91
|
}
|