stimeo-ui 0.14.0 → 0.16.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 +212 -0
- package/README.md +120 -0
- package/dist/cable/index.js +123 -29
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +30 -3
- package/dist/controllers/accordion_controller.js +98 -9
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +8 -7
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.d.ts +3 -2
- package/dist/controllers/announcer_controller.js +96 -62
- package/dist/controllers/announcer_controller.js.map +1 -1
- package/dist/controllers/auto_submit_controller.js +83 -7
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/avatar_controller.js +1 -1
- package/dist/controllers/avatar_controller.js.map +1 -1
- package/dist/controllers/breadcrumb_controller.d.ts +10 -7
- package/dist/controllers/breadcrumb_controller.js +38 -11
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/bulk_select_controller.d.ts +4 -4
- package/dist/controllers/bulk_select_controller.js +7 -6
- package/dist/controllers/bulk_select_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +101 -23
- package/dist/controllers/calendar_controller.js +340 -123
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +67 -22
- package/dist/controllers/carousel_controller.js +263 -38
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.d.ts +1 -1
- package/dist/controllers/character_counter_controller.js +40 -2
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/checkbox_controller.js +81 -12
- package/dist/controllers/checkbox_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +28 -2
- package/dist/controllers/clipboard_controller.js +63 -5
- package/dist/controllers/clipboard_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.d.ts +25 -1
- package/dist/controllers/collapsible_controller.js +99 -14
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +29 -8
- package/dist/controllers/color_picker_controller.js +80 -34
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +10 -1
- package/dist/controllers/combobox_controller.js +106 -16
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +3 -3
- package/dist/controllers/command_palette_controller.js +35 -3
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/conditional_fields_controller.js +85 -17
- package/dist/controllers/conditional_fields_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +3 -0
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +6 -0
- package/dist/controllers/context_menu_controller.js +32 -12
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +30 -1
- package/dist/controllers/countdown_controller.js +129 -26
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +77 -16
- package/dist/controllers/currency_input_controller.js +221 -67
- package/dist/controllers/currency_input_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +63 -18
- package/dist/controllers/data_grid_controller.js +195 -29
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +41 -6
- package/dist/controllers/date_range_picker_controller.js +151 -30
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.d.ts +10 -3
- package/dist/controllers/dialog_controller.js +35 -8
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.js +22 -4
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/dirty_form_controller.d.ts +2 -2
- package/dist/controllers/dirty_form_controller.js +46 -13
- package/dist/controllers/dirty_form_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.js +1 -0
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.d.ts +18 -10
- package/dist/controllers/drawer_controller.js +54 -19
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +9 -3
- package/dist/controllers/dropdown_controller.js +36 -9
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.js +34 -0
- package/dist/controllers/editable_controller.js.map +1 -1
- package/dist/controllers/file_dropzone_controller.js +144 -51
- package/dist/controllers/file_dropzone_controller.js.map +1 -1
- package/dist/controllers/filter_controller.d.ts +10 -4
- package/dist/controllers/filter_controller.js +20 -6
- package/dist/controllers/filter_controller.js.map +1 -1
- package/dist/controllers/flash_controller.d.ts +26 -6
- package/dist/controllers/flash_controller.js +432 -71
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.js +1 -0
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js +7 -5
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +19 -13
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.js +45 -8
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +82 -25
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +10 -2
- package/dist/controllers/hover_card_controller.js +40 -14
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/idle_controller.d.ts +16 -3
- package/dist/controllers/idle_controller.js +90 -5
- package/dist/controllers/idle_controller.js.map +1 -1
- package/dist/controllers/input_mask_controller.d.ts +5 -2
- package/dist/controllers/input_mask_controller.js +65 -9
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +3 -0
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +11 -2
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +50 -8
- package/dist/controllers/listbox_controller.js +203 -45
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.js +10 -5
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +3 -3
- package/dist/controllers/masonry_controller.js +31 -15
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/menu_controller.d.ts +9 -3
- package/dist/controllers/menu_controller.js +45 -16
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +11 -0
- package/dist/controllers/menubar_controller.js +58 -24
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/meter_controller.js +9 -5
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +18 -3
- package/dist/controllers/multi_select_controller.js +278 -104
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.d.ts +11 -0
- package/dist/controllers/navigation_menu_controller.js +48 -15
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.js +37 -8
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.js +9 -1
- package/dist/controllers/network_status_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +36 -8
- package/dist/controllers/number_input_controller.js +124 -21
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/optimistic_controller.js +42 -5
- package/dist/controllers/optimistic_controller.js.map +1 -1
- package/dist/controllers/otp_controller.d.ts +22 -7
- package/dist/controllers/otp_controller.js +198 -55
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +8 -12
- package/dist/controllers/overflow_indicator_controller.js +115 -21
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +26 -6
- package/dist/controllers/overflow_menu_controller.js +141 -44
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +19 -11
- package/dist/controllers/pagination_controller.js +74 -28
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +15 -1
- package/dist/controllers/password_reveal_controller.js +59 -2
- package/dist/controllers/password_reveal_controller.js.map +1 -1
- package/dist/controllers/persist_controller.js +30 -8
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.js +131 -52
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +9 -3
- package/dist/controllers/popover_controller.js +45 -11
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +1 -1
- package/dist/controllers/portal_controller.js +6 -2
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/preview_guard_controller.js +16 -1
- package/dist/controllers/preview_guard_controller.js.map +1 -1
- package/dist/controllers/progress_controller.js +8 -4
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.d.ts +6 -4
- package/dist/controllers/radio_group_controller.js +42 -17
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +49 -1
- package/dist/controllers/range_slider_controller.js +88 -42
- package/dist/controllers/range_slider_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +14 -3
- package/dist/controllers/rating_controller.js +39 -15
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +24 -2
- package/dist/controllers/read_more_controller.js +100 -7
- package/dist/controllers/read_more_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.js +65 -19
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.js +10 -5
- package/dist/controllers/relative_time_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +16 -2
- package/dist/controllers/resizable_controller.js +82 -22
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js +75 -27
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.js +37 -16
- package/dist/controllers/scroll_restore_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.js +49 -30
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +3 -2
- package/dist/controllers/scrollspy_controller.js +71 -26
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +41 -14
- package/dist/controllers/separator_controller.js +66 -37
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +20 -3
- package/dist/controllers/sidebar_controller.js +77 -18
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.js +6 -1
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +45 -7
- package/dist/controllers/slider_controller.js +82 -47
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.js +60 -26
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.js +17 -2
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.js +10 -2
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +19 -17
- package/dist/controllers/step_indicator_controller.js +18 -17
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +34 -9
- package/dist/controllers/stepper_controller.js +101 -19
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +19 -0
- package/dist/controllers/stick_to_bottom_controller.js +104 -8
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +3 -2
- package/dist/controllers/submit_once_controller.js +45 -9
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.d.ts +29 -7
- package/dist/controllers/switch_controller.js +101 -10
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +12 -0
- package/dist/controllers/tabs_controller.js +21 -2
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +15 -3
- package/dist/controllers/tags_input_controller.js +209 -59
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.js +29 -3
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +20 -3
- package/dist/controllers/theme_controller.js +64 -14
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.js +23 -8
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +55 -15
- package/dist/controllers/toast_controller.js +451 -105
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.d.ts +49 -7
- package/dist/controllers/toggle_group_controller.js +159 -23
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.js +32 -0
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +8 -0
- package/dist/controllers/tooltip_controller.js +39 -13
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.js +4 -0
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +39 -8
- package/dist/controllers/tree_view_controller.js +169 -16
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.js +5002 -1911
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +72 -6
- package/dist/inspector/cli.js +262 -51
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +309 -51
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +40 -40
- package/dist/inspector/manifest.json +529 -61
- package/dist/positioning/index.js +2 -0
- package/dist/positioning/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -77,11 +77,53 @@ function validSelector(element, raw, fallback) {
|
|
|
77
77
|
);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// src/utils/frame_coalescer.ts
|
|
81
|
+
var FrameCoalescer = class {
|
|
82
|
+
#frame = null;
|
|
83
|
+
/**
|
|
84
|
+
* Runs `run` on the next frame, unless a frame is already pending — the first
|
|
85
|
+
* request of a burst wins and the rest are dropped. The pending frame is
|
|
86
|
+
* released before `run`, so `run` may request the next one.
|
|
87
|
+
*/
|
|
88
|
+
schedule(run) {
|
|
89
|
+
if (this.#frame !== null) return;
|
|
90
|
+
this.#frame = requestAnimationFrame(() => {
|
|
91
|
+
this.#frame = null;
|
|
92
|
+
run();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Drops the pending frame, and reaches the platform only when there is one.
|
|
97
|
+
*
|
|
98
|
+
* There is no handle value that stands for "nothing pending":
|
|
99
|
+
* `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
|
|
100
|
+
* arrives as a large positive number that the same allocator can hand out, and
|
|
101
|
+
* an idle cancel would then drop a frame belonging to someone else.
|
|
102
|
+
*/
|
|
103
|
+
cancel() {
|
|
104
|
+
if (this.#frame === null) return;
|
|
105
|
+
cancelAnimationFrame(this.#frame);
|
|
106
|
+
this.#frame = null;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
80
110
|
// src/utils/reduced_motion.ts
|
|
81
111
|
function prefersReducedMotion() {
|
|
82
112
|
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
83
113
|
}
|
|
84
114
|
|
|
115
|
+
// src/utils/scroll_source.ts
|
|
116
|
+
function resolveScrollContainer(selector) {
|
|
117
|
+
const match = selector ? document.querySelector(selector) : null;
|
|
118
|
+
return match instanceof HTMLElement ? match : null;
|
|
119
|
+
}
|
|
120
|
+
function resolveScrollSource(selector) {
|
|
121
|
+
return resolveScrollContainer(selector) ?? window;
|
|
122
|
+
}
|
|
123
|
+
function scrollOffset(source) {
|
|
124
|
+
return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
|
|
125
|
+
}
|
|
126
|
+
|
|
85
127
|
// src/utils/before_cache_reset.ts
|
|
86
128
|
var BeforeCacheReset = class _BeforeCacheReset {
|
|
87
129
|
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
@@ -154,8 +196,8 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
154
196
|
};
|
|
155
197
|
static actions = ["toTop"];
|
|
156
198
|
static events = ["change"];
|
|
157
|
-
/**
|
|
158
|
-
#
|
|
199
|
+
/** Coalesces scroll bursts into one measurement per frame. */
|
|
200
|
+
#frames = new FrameCoalescer();
|
|
159
201
|
/** Previous scroll position, for `direction` mode delta detection. */
|
|
160
202
|
#lastScrollY = 0;
|
|
161
203
|
/** Current visibility, tracked to dispatch `change` only on real transitions. */
|
|
@@ -191,16 +233,10 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
191
233
|
#pendingHide = new BlurDeferral(() => {
|
|
192
234
|
if (this.#connected) this.#evaluate();
|
|
193
235
|
});
|
|
194
|
-
#onScroll = () =>
|
|
195
|
-
if (this.#rafId !== null) return;
|
|
196
|
-
this.#rafId = requestAnimationFrame(() => {
|
|
197
|
-
this.#rafId = null;
|
|
198
|
-
this.#evaluate();
|
|
199
|
-
});
|
|
200
|
-
};
|
|
236
|
+
#onScroll = () => this.#frames.schedule(() => this.#evaluate());
|
|
201
237
|
connect() {
|
|
202
|
-
this.#scrollSource = this.#
|
|
203
|
-
this.#lastScrollY = this.#
|
|
238
|
+
this.#scrollSource = resolveScrollSource(this.#rootSelector);
|
|
239
|
+
this.#lastScrollY = scrollOffset(this.#scrollSource);
|
|
204
240
|
this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
205
241
|
this.#evaluate(false);
|
|
206
242
|
this.#connected = true;
|
|
@@ -208,10 +244,7 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
208
244
|
disconnect() {
|
|
209
245
|
this.#connected = false;
|
|
210
246
|
this.#scrollSource.removeEventListener("scroll", this.#onScroll);
|
|
211
|
-
|
|
212
|
-
cancelAnimationFrame(this.#rafId);
|
|
213
|
-
this.#rafId = null;
|
|
214
|
-
}
|
|
247
|
+
this.#frames.cancel();
|
|
215
248
|
this.#pendingHide.releaseAll();
|
|
216
249
|
this.#tabindex.returnAll();
|
|
217
250
|
this.#visible = null;
|
|
@@ -270,7 +303,7 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
270
303
|
* @stimeoRenderRoot
|
|
271
304
|
*/
|
|
272
305
|
#evaluate(notify = true) {
|
|
273
|
-
const y = this.#
|
|
306
|
+
const y = scrollOffset(this.#scrollSource);
|
|
274
307
|
let nextVisible;
|
|
275
308
|
if (this.modeValue === "direction") {
|
|
276
309
|
if (y <= this.#offset) {
|
|
@@ -299,14 +332,6 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
299
332
|
this.element.setAttribute("data-state", next ? "visible" : "hidden");
|
|
300
333
|
if (notify) this.dispatch("change", { detail: { visible: next } });
|
|
301
334
|
}
|
|
302
|
-
/** Resolves the scroll source from `root` (falling back to the window). */
|
|
303
|
-
#resolveScrollSource() {
|
|
304
|
-
if (this.#rootSelector) {
|
|
305
|
-
const root = document.querySelector(this.#rootSelector);
|
|
306
|
-
if (root) return root;
|
|
307
|
-
}
|
|
308
|
-
return window;
|
|
309
|
-
}
|
|
310
335
|
/**
|
|
311
336
|
* The focus owner inside the target, or `null` when focus is elsewhere.
|
|
312
337
|
*
|
|
@@ -319,12 +344,6 @@ var ScrollVisibilityController = class extends Controller {
|
|
|
319
344
|
if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;
|
|
320
345
|
return null;
|
|
321
346
|
}
|
|
322
|
-
#scrollY() {
|
|
323
|
-
if (this.#scrollSource === window) {
|
|
324
|
-
return window.scrollY ?? window.pageYOffset ?? 0;
|
|
325
|
-
}
|
|
326
|
-
return this.#scrollSource.scrollTop;
|
|
327
|
-
}
|
|
328
347
|
};
|
|
329
348
|
|
|
330
349
|
export { ScrollVisibilityController };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/blur_deferral.ts","../../src/utils/declared_value.ts","../../src/utils/reduced_motion.ts","../../src/utils/before_cache_reset.ts","../../src/utils/tabindex_loan.ts","../../src/controllers/scroll_visibility_controller.ts"],"names":[],"mappings":"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","file":"scroll_visibility_controller.js","sourcesContent":["/**\n * Shared bookkeeping for updates that must wait until an element loses focus.\n *\n * A control that becomes redundant while the user is standing on it cannot be\n * removed from the page immediately: hiding a focused element (or turning it\n * into a native `disabled` one) drops focus to `<body>`, stranding a keyboard\n * user mid-widget. The fix every affected controller reached for is the same —\n * keep the element reachable, hold the destructive update, listen for `blur`,\n * and apply it then.\n *\n * {@link BlurDeferral} owns *only* the registry part of that: which elements are\n * currently holding an update back, attaching and detaching the `blur` listener,\n * and guaranteeing teardown. It is deliberately **policy-free** — what the\n * interim state looks like (`aria-disabled`, an ownership marker, nothing at\n * all), and what the deferred update actually is, stay in the controller, which\n * receives the element back through its release callback.\n *\n * Three contracts are worth stating up front, because each one is a hazard the\n * consumers would otherwise hit:\n *\n * - **The release callback fires only on a real `blur`.** `release()` /\n * `releaseAll()` *cancel* a deferral; they do not complete it. Conflating the\n * two makes `overflow-indicator` disable a button that just left the boundary.\n * - **`elements` is a snapshot.** Iterating the live key set while releasing\n * inside the loop is the failure this registry exists to prevent.\n * - **The entry is detached before the callback runs**, so a controller that\n * decides the update is still unsafe can simply defer again.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and contracts belong on the members.\n *\n * @example\n * ```ts\n * readonly #deferred = new BlurDeferral<HTMLElement>((trigger) => {\n * if (this.#connected) this.#evaluate();\n * });\n *\n * #hide(trigger: HTMLElement) {\n * if (document.activeElement === trigger) {\n * this.#deferred.deferOnly(trigger); // stays visible until blur\n * return;\n * }\n * this.#deferred.releaseAll();\n * trigger.hidden = true;\n * }\n *\n * disconnect() {\n * this.#deferred.releaseAll();\n * }\n * ```\n */\nexport class BlurDeferral<T extends HTMLElement = HTMLElement> {\n /** Elements currently holding an update back, mapped to their `blur` listener. */\n readonly #pending = new Map<T, () => void>();\n /** Called after a pending element blurs and has been detached. */\n readonly #onRelease: (element: T) => void;\n\n /** @param onRelease - Invoked once `element` actually blurs; never on `release`. */\n constructor(onRelease: (element: T) => void) {\n this.#onRelease = onRelease;\n }\n\n /** Number of elements currently holding an update back. */\n get size(): number {\n return this.#pending.size;\n }\n\n /** Snapshot of the pending elements, safe to iterate while releasing them. */\n get elements(): T[] {\n return [...this.#pending.keys()];\n }\n\n /** Whether `element` is currently holding an update back. */\n has(element: T): boolean {\n return this.#pending.has(element);\n }\n\n /** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */\n defer(element: T): void {\n if (this.#pending.has(element)) return;\n const onBlur = (): void => {\n this.#detach(element);\n this.#onRelease(element);\n };\n this.#pending.set(element, onBlur);\n element.addEventListener(\"blur\", onBlur);\n }\n\n /** Defers `element` as the only pending entry, cancelling any others. */\n deferOnly(element: T): void {\n for (const pending of this.elements) {\n if (pending !== element) this.#detach(pending);\n }\n this.defer(element);\n }\n\n /** Cancels `element`'s deferral without completing it; no-ops when not pending. */\n release(element: T): void {\n this.#detach(element);\n }\n\n /** Cancels every deferral without completing any of them. */\n releaseAll(): void {\n for (const element of this.elements) this.#detach(element);\n }\n\n /** Removes the `blur` listener for `element` and forgets it. */\n #detach(element: T): void {\n const onBlur = this.#pending.get(element);\n if (onBlur) element.removeEventListener(\"blur\", onBlur);\n this.#pending.delete(element);\n }\n}\n","/**\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 `prefers-reduced-motion` lookup for the motion-aware controllers\n *.\n *\n * This one-liner keeps the media query string and the environment guard\n * single-sourced across them. The preference is intentionally re-read on every\n * call — the controllers check it at each animation/scroll start (WCAG 2.2\n * **2.3.3**), so flipping the OS setting takes effect immediately without any\n * listener or cache bookkeeping here.\n */\n\n/**\n * Whether the user currently requests reduced motion.\n *\n * @returns `true` when `(prefers-reduced-motion: reduce)` matches; `false`\n * otherwise, including environments without `window.matchMedia` (treated as\n * \"no preference\").\n */\nexport function prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n","/**\n * Runs a controller's \"return to the initial state\" pass just before Turbo\n * caches the page.\n *\n * **`disconnect()` cannot do this job, for two independent reasons.** Turbo\n * queues the clone from this event rather than taking it here, and the body swap\n * that runs the controller's `disconnect()` is queued separately — so which of\n * the two lands first is not something a controller can rely on, and a rewind\n * written in `disconnect()` may reach only the DOM being thrown away. In the\n * other direction, `disconnect()` also fires on an in-page move (Stimulus tears\n * down and reconnects the same element), where rewinding would wipe a\n * legitimately in-progress interaction — a spinner mid-load would vanish. One\n * timing is unreliable, the other is too eager; `turbo:before-cache` is the only\n * point that is exactly \"the page is about to be frozen\".\n *\n * Scope is the subscription only: registering on `activate()`, unregistering on\n * `deactivate()`, and one shared document listener no matter how many instances\n * are live. *What* to return to its initial state — which `data-state`, which\n * `hidden`, which `aria-busy` — stays in the controller, because no two\n * consumers answer it the same way.\n *\n * **Rewind state, not appearance.** The pass writes attributes the controller\n * itself owns; the visual result of those attributes is the consumer's CSS, and\n * a library that reached for style or class names would be guessing at markup\n * it does not own.\n *\n * Both entry points are idempotent, so the lifecycle hooks can call them\n * unconditionally: a second `activate()` does not double-subscribe and does not\n * make the callback run twice, and `deactivate()` on an instance that never\n * subscribed is a no-op.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #beforeCache = new BeforeCacheReset(() => this.#rewind());\n *\n * connect() { this.#beforeCache.activate(); }\n * disconnect() { this.#beforeCache.deactivate(); }\n * ```\n */\nexport class BeforeCacheReset {\n /** Every subscribed instance, iterated by the one shared document listener. */\n static readonly #subscribers = new Set<BeforeCacheReset>();\n\n /** The shared listener; installed while at least one instance is subscribed. */\n static readonly #onBeforeCache = (): void => {\n for (const subscriber of BeforeCacheReset.#subscribers) subscriber.#rewind();\n };\n\n readonly #rewind: () => void;\n\n /** @param rewind - the pass that returns this controller's state to its initial form. */\n constructor(rewind: () => void) {\n this.#rewind = rewind;\n }\n\n /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */\n activate(): void {\n const first = BeforeCacheReset.#subscribers.size === 0;\n BeforeCacheReset.#subscribers.add(this);\n if (first) {\n document.addEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n }\n\n /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */\n deactivate(): void {\n BeforeCacheReset.#subscribers.delete(this);\n if (BeforeCacheReset.#subscribers.size > 0) return;\n document.removeEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n}\n","import { BeforeCacheReset } from \"./before_cache_reset\";\n\n/**\n * Shared bookkeeping for a `tabindex` a controller lends an element temporarily.\n *\n * A controller that must move focus somewhere the author never made focusable\n * (a landmark root, a scroll destination) reaches for the same trick: add a\n * `tabindex` just-in-time and hand it back once it is no longer needed. The\n * borrow is the easy half; the return is what the two conditions below are for.\n *\n * **Returning needs two conditions, not one.** Owning the borrow is not enough:\n * the attribute must also still hold the value this instance wrote. A consumer\n * that changed it afterwards — `tabindex=\"0\"` to make the root its own Tab stop\n * — owns it now, and removing it there silently discards authored markup. The\n * bookkeeping is dropped either way, since the loan is over regardless of who\n * ends up owning the value.\n *\n * **Never borrow over an existing value.** An element that already carries a\n * `tabindex` is the author's to control, so there is nothing to lend and nothing\n * to return.\n *\n * Every live loan also owns a shared `turbo:before-cache` subscription. The loan\n * is returned before Turbo can copy it into a snapshot, so consumers get cache\n * safety without duplicating a lifecycle hook; `returnAll()` removes the\n * subscription again as soon as no loan remains.\n *\n * The registry is keyed by element, so a controller borrowing on a single\n * element (`this.element`) and one borrowing across a changing set of targets\n * use the same API — the single-element case is a set of one. It holds no\n * opinion about *when* to borrow or where focus goes next; that stays in the\n * controller.\n *\n * **The API is deliberately two methods.** This file's own doc block is dropped\n * from `dist`, but every member comment is inlined into **each** consumer entry\n * (`tsup` builds with `splitting: false`), so rationale belongs here, only the\n * contract belongs on the members, and every method no consumer calls is still\n * paid for once per consumer entry.\n *\n * @example\n * ```ts\n * readonly #tabindex = new TabindexLoan();\n *\n * #rescueFocus() {\n * this.#tabindex.lend(this.element);\n * this.element.focus();\n * }\n *\n * disconnect() {\n * this.#tabindex.returnAll();\n * }\n * ```\n */\nexport class TabindexLoan<T extends HTMLElement = HTMLElement> {\n readonly #value: string;\n readonly #lent = new Set<T>();\n /** Returns live loans before Turbo can copy them into its page snapshot. */\n readonly #beforeCache = new BeforeCacheReset(() => this.returnAll());\n\n /**\n * @param value - the `tabindex` to lend. `\"-1\"` (the default) is\n * programmatically focusable but not a Tab stop; `\"0\"` is a real Tab stop,\n * which a scroll region with no focusable content of its own needs.\n */\n constructor(value: string = \"-1\") {\n this.#value = value;\n }\n\n /** Lends `element` the value; no-ops when it already carries a `tabindex`. */\n lend(element: T): void {\n if (element.hasAttribute(\"tabindex\")) return;\n element.setAttribute(\"tabindex\", this.#value);\n this.#lent.add(element);\n // Subscribe only while a real loan exists. Keeping this guarantee here means\n // every consumer is Turbo-safe without another lifecycle hook to remember.\n this.#beforeCache.activate();\n }\n\n /** Takes back every loan whose value is still the one that was lent. */\n returnAll(): void {\n for (const element of this.#lent) {\n if (element.getAttribute(\"tabindex\") === this.#value) element.removeAttribute(\"tabindex\");\n }\n this.#lent.clear();\n this.#beforeCache.deactivate();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { BlurDeferral } from \"../utils/blur_deferral\";\nimport { validSelector } from \"../utils/declared_value\";\nimport { prefersReducedMotion } from \"../utils/reduced_motion\";\nimport { TabindexLoan } from \"../utils/tabindex_loan\";\n\n/** Default scroll threshold in px, and what a non-finite declaration falls back to. */\nconst DEFAULT_OFFSET = 400;\n\n/**\n * Headless **Scroll Visibility** behavior: shows or hides an element based on\n * scroll amount or direction (back-to-top buttons, hide-on-scroll headers). No\n * dedicated APG pattern; when the element is a button it follows the Button\n * practice.\n *\n * Markup contract (identifier: `stimeo--scroll-visibility`):\n * <div data-controller=\"stimeo--scroll-visibility\"\n * data-stimeo--scroll-visibility-offset-value=\"400\"\n * data-stimeo--scroll-visibility-mode-value=\"offset\">\n * <button type=\"button\" hidden\n * data-stimeo--scroll-visibility-target=\"element\"\n * data-action=\"click->stimeo--scroll-visibility#toTop\">Back to top</button>\n * </div>\n *\n * In `offset` mode the element is shown once the scroll source is scrolled past\n * `offset` px; in `direction` mode it is hidden while scrolling down and shown\n * while scrolling up. A `scroll` that carries no vertical movement — a\n * horizontal scroll, an overscroll bounce — has no direction, so `direction`\n * mode leaves the current visibility alone. Visibility is reflected through the\n * `hidden` attribute (so a hidden control also leaves the focus order) and\n * `data-state`.\n *\n * By default the **window** is the scroll source. When the page itself does not\n * scroll — e.g. a fixed-height app shell whose main column scrolls in a container\n * (`overflow: auto`) — point `root` at that container (a CSS selector) so the\n * controller observes the element's scroll instead of the (never-scrolling)\n * window. `toTop` then scrolls that same container.\n *\n * `change` dispatches `{ visible: boolean }` on transitions only: connecting\n * reflects the current scroll state onto the hooks without announcing it, so a\n * Turbo restore does not replay the state the snapshot already carries.\n *\n * @remarks\n * Behavior only — the look and any transition are the consumer's CSS. The scroll\n * listener is `passive`, coalesced through `requestAnimationFrame`, and removed on\n * `disconnect()` (Turbo navigation included). `offset` / `mode` are re-read at\n * runtime, so a Turbo morph that swaps either attribute is followed without\n * waiting for the next scroll; a selector (`root`, `focusSelector`) or a\n * threshold that cannot be parsed reads as its default, keeping the rest of the\n * element alive. **A scroll never hides the control while it owns focus**: that\n * hide waits for it to blur and is decided again from the scroll position at that\n * moment. `toTop` honors `prefers-reduced-motion` by forcing an\n * instant jump independently of the consumer's CSS `scroll-behavior`, and can\n * move focus to a `focusSelector` target (given `tabindex=\"-1\"` if needed, and\n * focused without scrolling so the smooth scroll survives) to keep keyboard\n * users oriented after the scroll. A live disconnect removes only a\n * `tabindex=\"-1\"` this controller instance added; authored tabindex values\n * remain. This teardown does not claim to rewrite a Turbo cache snapshot that\n * was cloned before disconnect.\n */\nexport class ScrollVisibilityController extends Controller<HTMLElement> {\n static override targets = [\"element\"];\n static override values = {\n offset: { type: Number, default: DEFAULT_OFFSET },\n mode: { type: String, default: \"offset\" },\n focusSelector: { type: String, default: \"\" },\n root: { type: String, default: \"\" },\n };\n static actions = [\"toTop\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly elementTarget: HTMLElement;\n declare readonly hasElementTarget: boolean;\n\n declare offsetValue: number;\n declare modeValue: string;\n declare focusSelectorValue: string;\n declare rootValue: string;\n\n /** Pending rAF id that coalesces scroll bursts into one measurement. */\n #rafId: number | null = null;\n /** Previous scroll position, for `direction` mode delta detection. */\n #lastScrollY = 0;\n /** Current visibility, tracked to dispatch `change` only on real transitions. */\n #visible: boolean | null = null;\n /**\n * The observed scroll source: a container element when `root` resolves, else\n * the window. Captured on connect so teardown detaches from the same source.\n */\n #scrollSource: HTMLElement | Window = window;\n /**\n * Gates the declaration callbacks to the connected window.\n *\n * Stimulus delivers a Value callback ahead of `connect()` and again for every\n * runtime change; without the gate, merely connecting would evaluate — and\n * announce — before `connect()` runs its own first reflection.\n */\n #connected = false;\n /** Validated threshold; a non-finite declaration reads as the default. */\n #offset = DEFAULT_OFFSET;\n /** Validated `root` selector; an unparsable declaration reads as absent. */\n #rootSelector = \"\";\n /** Validated `focusSelector`; an unparsable declaration reads as absent. */\n #focusSelector = \"\";\n /** Focus targets this instance lent a `tabindex` to. */\n readonly #tabindex = new TabindexLoan();\n /**\n * The hide held back while the control itself owns focus.\n *\n * Completing the deferral re-runs the ordinary evaluation rather than applying\n * the stale decision: by the time focus leaves, the scroll position may have\n * moved back past the threshold.\n */\n readonly #pendingHide = new BlurDeferral((): void => {\n if (this.#connected) this.#evaluate();\n });\n\n readonly #onScroll = (): void => {\n if (this.#rafId !== null) return;\n this.#rafId = requestAnimationFrame(() => {\n this.#rafId = null;\n this.#evaluate();\n });\n };\n\n override connect(): void {\n this.#scrollSource = this.#resolveScrollSource();\n this.#lastScrollY = this.#scrollY();\n this.#scrollSource.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n this.#evaluate(false);\n this.#connected = true;\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#scrollSource.removeEventListener(\"scroll\", this.#onScroll);\n if (this.#rafId !== null) {\n cancelAnimationFrame(this.#rafId);\n this.#rafId = null;\n }\n this.#pendingHide.releaseAll();\n this.#tabindex.returnAll();\n this.#visible = null;\n }\n\n /** Writes the current visibility onto a control that arrives after connect. */\n elementTargetConnected(element: HTMLElement): void {\n if (this.#visible !== null) element.hidden = !this.#visible;\n }\n\n /** Drops a held-back hide together with the control it was waiting on. */\n elementTargetDisconnected(): void {\n // At most one hide is ever held back, and it rides an element inside the\n // target — the focus owner, which may be a descendant rather than the\n // target itself. Losing the target ends that wait either way.\n this.#pendingHide.releaseAll();\n }\n\n /**\n * Validates `offset` once, then re-renders.\n *\n * Re-renders when application code (or a Turbo morph) changes `offset` at\n * runtime. A declaration that is not a finite number reads as the default, so\n * the comparison path never sees `NaN` — which would answer `false` to every\n * comparison and strand the element (in `direction` mode, even the guarantee\n * that the very top always reveals).\n */\n offsetValueChanged(): void {\n this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;\n if (this.#connected) this.#evaluate();\n }\n\n /** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */\n modeValueChanged(): void {\n if (this.#connected) this.#evaluate();\n }\n\n /** Validates `root` once so connect never parses a selector that throws. */\n rootValueChanged(): void {\n this.#rootSelector = validSelector(this.element, this.rootValue, \"\");\n }\n\n /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */\n focusSelectorValueChanged(): void {\n this.#focusSelector = validSelector(this.element, this.focusSelectorValue, \"\");\n }\n\n /** Scrolls the source to the top and, optionally, moves focus to a safe target. */\n toTop(): void {\n const behavior: ScrollBehavior = prefersReducedMotion() ? \"instant\" : \"smooth\";\n this.#scrollSource.scrollTo({ top: 0, behavior });\n if (this.#focusSelector) {\n const target = document.querySelector<HTMLElement>(this.#focusSelector);\n if (target) {\n this.#tabindex.lend(target);\n // Focusing scrolls the target into view by default, which lands the page\n // instantly and discards the scroll above.\n target.focus({ preventScroll: true });\n }\n }\n }\n\n /**\n * Decides the next visibility from the current scroll state and applies it.\n *\n * @param notify - whether a transition announces itself. The reflection\n * `connect()` performs is the current state, not a change.\n *\n * @stimeoRenderRoot\n */\n #evaluate(notify = true): void {\n const y = this.#scrollY();\n let nextVisible: boolean;\n if (this.modeValue === \"direction\") {\n // Near the very top, always reveal so a hide-on-scroll header is never\n // stranded off-screen when the page cannot scroll up any further.\n if (y <= this.#offset) {\n nextVisible = true;\n } else if (y === this.#lastScrollY) {\n // No vertical movement carries no direction, so it decides nothing.\n return;\n } else {\n nextVisible = y < this.#lastScrollY; // scrolling up reveals, down hides\n }\n } else {\n nextVisible = y > this.#offset;\n }\n this.#lastScrollY = y;\n this.#setVisible(nextVisible, notify);\n }\n\n /** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */\n #setVisible(next: boolean, notify: boolean): void {\n if (next === this.#visible) return;\n const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;\n if (focused) {\n // Hiding the element that holds focus drops it to the document body,\n // stranding a keyboard user mid-interaction (WCAG 2.4.7 / 2.4.11). Hold\n // the hide until it blurs; the state stays what the user can still see.\n this.#pendingHide.deferOnly(focused);\n return;\n }\n this.#visible = next;\n if (this.hasElementTarget) this.elementTarget.hidden = !next;\n this.element.setAttribute(\"data-state\", next ? \"visible\" : \"hidden\");\n if (notify) this.dispatch(\"change\", { detail: { visible: next } });\n }\n\n /** Resolves the scroll source from `root` (falling back to the window). */\n #resolveScrollSource(): HTMLElement | Window {\n if (this.#rootSelector) {\n const root = document.querySelector<HTMLElement>(this.#rootSelector);\n if (root) return root;\n }\n return window;\n }\n\n /**\n * The focus owner inside the target, or `null` when focus is elsewhere.\n *\n * `blur` does not bubble, so the deferral has to ride the focused element\n * itself: waiting on a container that never receives the event would hold the\n * hide forever.\n */\n #focusedWithin(): HTMLElement | null {\n const focused = document.activeElement;\n if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;\n return null;\n }\n\n #scrollY(): number {\n if (this.#scrollSource === window) {\n return window.scrollY ?? window.pageYOffset ?? 0;\n }\n return (this.#scrollSource as HTMLElement).scrollTop;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/blur_deferral.ts","../../src/utils/declared_value.ts","../../src/utils/frame_coalescer.ts","../../src/utils/reduced_motion.ts","../../src/utils/scroll_source.ts","../../src/utils/before_cache_reset.ts","../../src/utils/tabindex_loan.ts","../../src/controllers/scroll_visibility_controller.ts"],"names":[],"mappings":"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","file":"scroll_visibility_controller.js","sourcesContent":["/**\n * Shared bookkeeping for updates that must wait until an element loses focus.\n *\n * A control that becomes redundant while the user is standing on it cannot be\n * removed from the page immediately: hiding a focused element (or turning it\n * into a native `disabled` one) drops focus to `<body>`, stranding a keyboard\n * user mid-widget. The fix every affected controller reached for is the same —\n * keep the element reachable, hold the destructive update, listen for `blur`,\n * and apply it then.\n *\n * {@link BlurDeferral} owns *only* the registry part of that: which elements are\n * currently holding an update back, attaching and detaching the `blur` listener,\n * and guaranteeing teardown. It is deliberately **policy-free** — what the\n * interim state looks like (`aria-disabled`, an ownership marker, nothing at\n * all), and what the deferred update actually is, stay in the controller, which\n * receives the element back through its release callback.\n *\n * Three contracts are worth stating up front, because each one is a hazard the\n * consumers would otherwise hit:\n *\n * - **The release callback fires only on a real `blur`.** `release()` /\n * `releaseAll()` *cancel* a deferral; they do not complete it. Conflating the\n * two makes `overflow-indicator` disable a button that just left the boundary.\n * - **`elements` is a snapshot.** Iterating the live key set while releasing\n * inside the loop is the failure this registry exists to prevent.\n * - **The entry is detached before the callback runs**, so a controller that\n * decides the update is still unsafe can simply defer again.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and contracts belong on the members.\n *\n * @example\n * ```ts\n * readonly #deferred = new BlurDeferral<HTMLElement>((trigger) => {\n * if (this.#connected) this.#evaluate();\n * });\n *\n * #hide(trigger: HTMLElement) {\n * if (document.activeElement === trigger) {\n * this.#deferred.deferOnly(trigger); // stays visible until blur\n * return;\n * }\n * this.#deferred.releaseAll();\n * trigger.hidden = true;\n * }\n *\n * disconnect() {\n * this.#deferred.releaseAll();\n * }\n * ```\n */\nexport class BlurDeferral<T extends HTMLElement = HTMLElement> {\n /** Elements currently holding an update back, mapped to their `blur` listener. */\n readonly #pending = new Map<T, () => void>();\n /** Called after a pending element blurs and has been detached. */\n readonly #onRelease: (element: T) => void;\n\n /** @param onRelease - Invoked once `element` actually blurs; never on `release`. */\n constructor(onRelease: (element: T) => void) {\n this.#onRelease = onRelease;\n }\n\n /** Number of elements currently holding an update back. */\n get size(): number {\n return this.#pending.size;\n }\n\n /** Snapshot of the pending elements, safe to iterate while releasing them. */\n get elements(): T[] {\n return [...this.#pending.keys()];\n }\n\n /** Whether `element` is currently holding an update back. */\n has(element: T): boolean {\n return this.#pending.has(element);\n }\n\n /** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */\n defer(element: T): void {\n if (this.#pending.has(element)) return;\n const onBlur = (): void => {\n this.#detach(element);\n this.#onRelease(element);\n };\n this.#pending.set(element, onBlur);\n element.addEventListener(\"blur\", onBlur);\n }\n\n /** Defers `element` as the only pending entry, cancelling any others. */\n deferOnly(element: T): void {\n for (const pending of this.elements) {\n if (pending !== element) this.#detach(pending);\n }\n this.defer(element);\n }\n\n /** Cancels `element`'s deferral without completing it; no-ops when not pending. */\n release(element: T): void {\n this.#detach(element);\n }\n\n /** Cancels every deferral without completing any of them. */\n releaseAll(): void {\n for (const element of this.elements) this.#detach(element);\n }\n\n /** Removes the `blur` listener for `element` and forgets it. */\n #detach(element: T): void {\n const onBlur = this.#pending.get(element);\n if (onBlur) element.removeEventListener(\"blur\", onBlur);\n this.#pending.delete(element);\n }\n}\n","/**\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 * Runs one piece of scroll-driven work per animation frame.\n *\n * A `scroll` listener fires many times between two paints, and each firing would\n * measure the same layout. Requesting a frame on the first firing and ignoring\n * the rest until it runs keeps the reads to one per frame; running on the frame\n * — rather than on a timer — keeps the measurement in step with what the reader\n * sees.\n *\n * **The first request of a burst wins.** Work handed in while a frame is already\n * pending is dropped, so a caller that queues a special first pass and then\n * receives scrolls gets that first pass, not the last scroll's.\n *\n * Cancelling drops the pending frame outright, and does nothing at all when none\n * is pending. A frame really is cancelled, so nothing needs a generation to tell\n * a stale callback from a fresh one, and nothing has to be refused before the\n * caller starts listening.\n *\n * Scope is the scheduling only. What to measure stays with the caller.\n *\n * @example\n * ```ts\n * readonly #frames = new FrameCoalescer();\n *\n * readonly #onScroll = (): void => this.#frames.schedule(() => this.#measure());\n *\n * disconnect(): void {\n * this.#frames.cancel();\n * }\n * ```\n */\nexport class FrameCoalescer {\n #frame: number | null = null;\n\n /**\n * Runs `run` on the next frame, unless a frame is already pending — the first\n * request of a burst wins and the rest are dropped. The pending frame is\n * released before `run`, so `run` may request the next one.\n */\n schedule(run: () => void): void {\n if (this.#frame !== null) return;\n this.#frame = requestAnimationFrame(() => {\n this.#frame = null;\n run();\n });\n }\n\n /**\n * Drops the pending frame, and reaches the platform only when there is one.\n *\n * There is no handle value that stands for \"nothing pending\":\n * `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder\n * arrives as a large positive number that the same allocator can hand out, and\n * an idle cancel would then drop a frame belonging to someone else.\n */\n cancel(): void {\n if (this.#frame === null) return;\n cancelAnimationFrame(this.#frame);\n this.#frame = null;\n }\n}\n","/**\n * Shared `prefers-reduced-motion` lookup for the motion-aware controllers.\n *\n * This one-liner keeps the media query string and the environment guard\n * single-sourced across them. The preference is intentionally re-read on every\n * call — the controllers check it at each animation/scroll start (WCAG 2.2\n * **2.3.3**), so flipping the OS setting takes effect immediately without any\n * listener or cache bookkeeping here.\n */\n\n/**\n * Whether the user currently requests reduced motion.\n *\n * @returns `true` when `(prefers-reduced-motion: reduce)` matches; `false`\n * otherwise, including environments without `window.matchMedia` (treated as\n * \"no preference\").\n */\nexport function prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n}\n","/**\n * What a scroll-driven component listens to and measures: a scroll container\n * somewhere in the page, or the window when nothing names one.\n *\n * A component that reacts to scrolling takes a selector for the container it\n * lives in and falls back to the viewport. Resolving that, and reading how far\n * the result has scrolled, are the same two steps everywhere — and the read is\n * the one that drifts, because the window and an element expose the offset under\n * different names.\n *\n * Resolution only. A selector reaches here already validated, or empty; whether\n * a declaration parses is decided once where it is declared, not on every\n * resolve.\n */\n\n/** A scroll container, or the window. */\nexport type ScrollSource = HTMLElement | Window;\n\n/**\n * The scroll container `selector` names, or `null` for the viewport.\n *\n * `null` covers every way a container can fail to be one: an empty selector, a\n * selector that matches nothing, and a match that is not an `HTMLElement` — an\n * SVG node answers a query but does not scroll.\n *\n * `selector` must be empty or already validated: an unparsable one makes the\n * query throw rather than fall back.\n */\nexport function resolveScrollContainer(selector: string): HTMLElement | null {\n const match = selector ? document.querySelector(selector) : null;\n return match instanceof HTMLElement ? match : null;\n}\n\n/** The scroll source `selector` names: its container, else the window. */\nexport function resolveScrollSource(selector: string): ScrollSource {\n return resolveScrollContainer(selector) ?? window;\n}\n\n/**\n * How far `source` has scrolled vertically.\n *\n * The window is compared by identity rather than with `instanceof`, which a\n * cross-realm or synthetic DOM fails. Its offset is read through the modern name\n * first and the legacy one after, so a runtime that exposes only one still gives\n * a number.\n */\nexport function scrollOffset(source: ScrollSource): number {\n return source === window\n ? (window.scrollY ?? window.pageYOffset ?? 0)\n : (source as HTMLElement).scrollTop;\n}\n","/**\n * Runs a controller's \"return to the initial state\" pass just before Turbo\n * caches the page.\n *\n * **`disconnect()` cannot do this job, for two independent reasons.** Turbo\n * queues the clone from this event rather than taking it here, and the body swap\n * that runs the controller's `disconnect()` is queued separately — so which of\n * the two lands first is not something a controller can rely on, and a rewind\n * written in `disconnect()` may reach only the DOM being thrown away. In the\n * other direction, `disconnect()` also fires on an in-page move (Stimulus tears\n * down and reconnects the same element), where rewinding would wipe a\n * legitimately in-progress interaction — a spinner mid-load would vanish. One\n * timing is unreliable, the other is too eager; `turbo:before-cache` is the only\n * point that is exactly \"the page is about to be frozen\".\n *\n * Scope is the subscription only: registering on `activate()`, unregistering on\n * `deactivate()`, and one shared document listener no matter how many instances\n * are live. *What* to return to its initial state — which `data-state`, which\n * `hidden`, which `aria-busy` — stays in the controller, because no two\n * consumers answer it the same way.\n *\n * **Rewind state, not appearance.** The pass writes attributes the controller\n * itself owns; the visual result of those attributes is the consumer's CSS, and\n * a library that reached for style or class names would be guessing at markup\n * it does not own.\n *\n * Both entry points are idempotent, so the lifecycle hooks can call them\n * unconditionally: a second `activate()` does not double-subscribe and does not\n * make the callback run twice, and `deactivate()` on an instance that never\n * subscribed is a no-op.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #beforeCache = new BeforeCacheReset(() => this.#rewind());\n *\n * connect() { this.#beforeCache.activate(); }\n * disconnect() { this.#beforeCache.deactivate(); }\n * ```\n */\nexport class BeforeCacheReset {\n /** Every subscribed instance, iterated by the one shared document listener. */\n static readonly #subscribers = new Set<BeforeCacheReset>();\n\n /** The shared listener; installed while at least one instance is subscribed. */\n static readonly #onBeforeCache = (): void => {\n for (const subscriber of BeforeCacheReset.#subscribers) subscriber.#rewind();\n };\n\n readonly #rewind: () => void;\n\n /** @param rewind - the pass that returns this controller's state to its initial form. */\n constructor(rewind: () => void) {\n this.#rewind = rewind;\n }\n\n /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */\n activate(): void {\n const first = BeforeCacheReset.#subscribers.size === 0;\n BeforeCacheReset.#subscribers.add(this);\n if (first) {\n document.addEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n }\n\n /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */\n deactivate(): void {\n BeforeCacheReset.#subscribers.delete(this);\n if (BeforeCacheReset.#subscribers.size > 0) return;\n document.removeEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n}\n","import { BeforeCacheReset } from \"./before_cache_reset\";\n\n/**\n * Shared bookkeeping for a `tabindex` a controller lends an element temporarily.\n *\n * A controller that must move focus somewhere the author never made focusable\n * (a landmark root, a scroll destination) reaches for the same trick: add a\n * `tabindex` just-in-time and hand it back once it is no longer needed. The\n * borrow is the easy half; the return is what the two conditions below are for.\n *\n * **Returning needs two conditions, not one.** Owning the borrow is not enough:\n * the attribute must also still hold the value this instance wrote. A consumer\n * that changed it afterwards — `tabindex=\"0\"` to make the root its own Tab stop\n * — owns it now, and removing it there silently discards authored markup. The\n * bookkeeping is dropped either way, since the loan is over regardless of who\n * ends up owning the value.\n *\n * **Never borrow over an existing value.** An element that already carries a\n * `tabindex` is the author's to control, so there is nothing to lend and nothing\n * to return.\n *\n * Every live loan also owns a shared `turbo:before-cache` subscription. The loan\n * is returned before Turbo can copy it into a snapshot, so consumers get cache\n * safety without duplicating a lifecycle hook; `returnAll()` removes the\n * subscription again as soon as no loan remains.\n *\n * The registry is keyed by element, so a controller borrowing on a single\n * element (`this.element`) and one borrowing across a changing set of targets\n * use the same API — the single-element case is a set of one. It holds no\n * opinion about *when* to borrow or where focus goes next; that stays in the\n * controller.\n *\n * **The API is deliberately two methods.** This file's own doc block is dropped\n * from `dist`, but every member comment is inlined into **each** consumer entry\n * (`tsup` builds with `splitting: false`), so rationale belongs here, only the\n * contract belongs on the members, and every method no consumer calls is still\n * paid for once per consumer entry.\n *\n * @example\n * ```ts\n * readonly #tabindex = new TabindexLoan();\n *\n * #rescueFocus() {\n * this.#tabindex.lend(this.element);\n * this.element.focus();\n * }\n *\n * disconnect() {\n * this.#tabindex.returnAll();\n * }\n * ```\n */\nexport class TabindexLoan<T extends HTMLElement = HTMLElement> {\n readonly #value: string;\n readonly #lent = new Set<T>();\n /** Returns live loans before Turbo can copy them into its page snapshot. */\n readonly #beforeCache = new BeforeCacheReset(() => this.returnAll());\n\n /**\n * @param value - the `tabindex` to lend. `\"-1\"` (the default) is\n * programmatically focusable but not a Tab stop; `\"0\"` is a real Tab stop,\n * which a scroll region with no focusable content of its own needs.\n */\n constructor(value: string = \"-1\") {\n this.#value = value;\n }\n\n /** Lends `element` the value; no-ops when it already carries a `tabindex`. */\n lend(element: T): void {\n if (element.hasAttribute(\"tabindex\")) return;\n element.setAttribute(\"tabindex\", this.#value);\n this.#lent.add(element);\n // Subscribe only while a real loan exists. Keeping this guarantee here means\n // every consumer is Turbo-safe without another lifecycle hook to remember.\n this.#beforeCache.activate();\n }\n\n /** Takes back every loan whose value is still the one that was lent. */\n returnAll(): void {\n for (const element of this.#lent) {\n if (element.getAttribute(\"tabindex\") === this.#value) element.removeAttribute(\"tabindex\");\n }\n this.#lent.clear();\n this.#beforeCache.deactivate();\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { BlurDeferral } from \"../utils/blur_deferral\";\nimport { validSelector } from \"../utils/declared_value\";\nimport { FrameCoalescer } from \"../utils/frame_coalescer\";\nimport { prefersReducedMotion } from \"../utils/reduced_motion\";\nimport { resolveScrollSource, scrollOffset } from \"../utils/scroll_source\";\nimport { TabindexLoan } from \"../utils/tabindex_loan\";\n\n/** Default scroll threshold in px, and what a non-finite declaration falls back to. */\nconst DEFAULT_OFFSET = 400;\n\n/**\n * Headless **Scroll Visibility** behavior: shows or hides an element based on\n * scroll amount or direction (back-to-top buttons, hide-on-scroll headers). No\n * dedicated APG pattern; when the element is a button it follows the Button\n * practice.\n *\n * Markup contract (identifier: `stimeo--scroll-visibility`):\n * <div data-controller=\"stimeo--scroll-visibility\"\n * data-stimeo--scroll-visibility-offset-value=\"400\"\n * data-stimeo--scroll-visibility-mode-value=\"offset\">\n * <button type=\"button\" hidden\n * data-stimeo--scroll-visibility-target=\"element\"\n * data-action=\"click->stimeo--scroll-visibility#toTop\">Back to top</button>\n * </div>\n *\n * In `offset` mode the element is shown once the scroll source is scrolled past\n * `offset` px; in `direction` mode it is hidden while scrolling down and shown\n * while scrolling up. A `scroll` that carries no vertical movement — a\n * horizontal scroll, an overscroll bounce — has no direction, so `direction`\n * mode leaves the current visibility alone. Visibility is reflected through the\n * `hidden` attribute (so a hidden control also leaves the focus order) and\n * `data-state`.\n *\n * By default the **window** is the scroll source. When the page itself does not\n * scroll — e.g. a fixed-height app shell whose main column scrolls in a container\n * (`overflow: auto`) — point `root` at that container (a CSS selector) so the\n * controller observes the element's scroll instead of the (never-scrolling)\n * window. `toTop` then scrolls that same container.\n *\n * `change` dispatches `{ visible: boolean }` on transitions only: connecting\n * reflects the current scroll state onto the hooks without announcing it, so a\n * Turbo restore does not replay the state the snapshot already carries.\n *\n * @remarks\n * Behavior only — the look and any transition are the consumer's CSS. The scroll\n * listener is `passive`, coalesced through `requestAnimationFrame`, and removed on\n * `disconnect()` (Turbo navigation included). `offset` / `mode` are re-read at\n * runtime, so a Turbo morph that swaps either attribute is followed without\n * waiting for the next scroll; a selector (`root`, `focusSelector`) or a\n * threshold that cannot be parsed reads as its default, keeping the rest of the\n * element alive. **A scroll never hides the control while it owns focus**: that\n * hide waits for it to blur and is decided again from the scroll position at that\n * moment. `toTop` honors `prefers-reduced-motion` by forcing an\n * instant jump independently of the consumer's CSS `scroll-behavior`, and can\n * move focus to a `focusSelector` target (given `tabindex=\"-1\"` if needed, and\n * focused without scrolling so the smooth scroll survives) to keep keyboard\n * users oriented after the scroll. A live disconnect removes only a\n * `tabindex=\"-1\"` this controller instance added; authored tabindex values\n * remain. This teardown does not claim to rewrite a Turbo cache snapshot that\n * was cloned before disconnect.\n */\nexport class ScrollVisibilityController extends Controller<HTMLElement> {\n static override targets = [\"element\"];\n static override values = {\n offset: { type: Number, default: DEFAULT_OFFSET },\n mode: { type: String, default: \"offset\" },\n focusSelector: { type: String, default: \"\" },\n root: { type: String, default: \"\" },\n };\n static actions = [\"toTop\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly elementTarget: HTMLElement;\n declare readonly hasElementTarget: boolean;\n\n declare offsetValue: number;\n declare modeValue: string;\n declare focusSelectorValue: string;\n declare rootValue: string;\n\n /** Coalesces scroll bursts into one measurement per frame. */\n readonly #frames = new FrameCoalescer();\n /** Previous scroll position, for `direction` mode delta detection. */\n #lastScrollY = 0;\n /** Current visibility, tracked to dispatch `change` only on real transitions. */\n #visible: boolean | null = null;\n /**\n * The observed scroll source: a container element when `root` resolves, else\n * the window. Captured on connect so teardown detaches from the same source.\n */\n #scrollSource: HTMLElement | Window = window;\n /**\n * Gates the declaration callbacks to the connected window.\n *\n * Stimulus delivers a Value callback ahead of `connect()` and again for every\n * runtime change; without the gate, merely connecting would evaluate — and\n * announce — before `connect()` runs its own first reflection.\n */\n #connected = false;\n /** Validated threshold; a non-finite declaration reads as the default. */\n #offset = DEFAULT_OFFSET;\n /** Validated `root` selector; an unparsable declaration reads as absent. */\n #rootSelector = \"\";\n /** Validated `focusSelector`; an unparsable declaration reads as absent. */\n #focusSelector = \"\";\n /** Focus targets this instance lent a `tabindex` to. */\n readonly #tabindex = new TabindexLoan();\n /**\n * The hide held back while the control itself owns focus.\n *\n * Completing the deferral re-runs the ordinary evaluation rather than applying\n * the stale decision: by the time focus leaves, the scroll position may have\n * moved back past the threshold.\n */\n readonly #pendingHide = new BlurDeferral((): void => {\n if (this.#connected) this.#evaluate();\n });\n\n readonly #onScroll = (): void => this.#frames.schedule(() => this.#evaluate());\n\n override connect(): void {\n this.#scrollSource = resolveScrollSource(this.#rootSelector);\n this.#lastScrollY = scrollOffset(this.#scrollSource);\n this.#scrollSource.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n this.#evaluate(false);\n this.#connected = true;\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#scrollSource.removeEventListener(\"scroll\", this.#onScroll);\n this.#frames.cancel();\n this.#pendingHide.releaseAll();\n this.#tabindex.returnAll();\n this.#visible = null;\n }\n\n /** Writes the current visibility onto a control that arrives after connect. */\n elementTargetConnected(element: HTMLElement): void {\n if (this.#visible !== null) element.hidden = !this.#visible;\n }\n\n /** Drops a held-back hide together with the control it was waiting on. */\n elementTargetDisconnected(): void {\n // At most one hide is ever held back, and it rides an element inside the\n // target — the focus owner, which may be a descendant rather than the\n // target itself. Losing the target ends that wait either way.\n this.#pendingHide.releaseAll();\n }\n\n /**\n * Validates `offset` once, then re-renders.\n *\n * Re-renders when application code (or a Turbo morph) changes `offset` at\n * runtime. A declaration that is not a finite number reads as the default, so\n * the comparison path never sees `NaN` — which would answer `false` to every\n * comparison and strand the element (in `direction` mode, even the guarantee\n * that the very top always reveals).\n */\n offsetValueChanged(): void {\n this.#offset = Number.isFinite(this.offsetValue) ? this.offsetValue : DEFAULT_OFFSET;\n if (this.#connected) this.#evaluate();\n }\n\n /** Re-renders when application code (or a Turbo morph) changes `mode` at runtime. */\n modeValueChanged(): void {\n if (this.#connected) this.#evaluate();\n }\n\n /** Validates `root` once so connect never parses a selector that throws. */\n rootValueChanged(): void {\n this.#rootSelector = validSelector(this.element, this.rootValue, \"\");\n }\n\n /** Validates `focusSelector` once so `toTop` never parses a selector that throws. */\n focusSelectorValueChanged(): void {\n this.#focusSelector = validSelector(this.element, this.focusSelectorValue, \"\");\n }\n\n /** Scrolls the source to the top and, optionally, moves focus to a safe target. */\n toTop(): void {\n const behavior: ScrollBehavior = prefersReducedMotion() ? \"instant\" : \"smooth\";\n this.#scrollSource.scrollTo({ top: 0, behavior });\n if (this.#focusSelector) {\n const target = document.querySelector<HTMLElement>(this.#focusSelector);\n if (target) {\n this.#tabindex.lend(target);\n // Focusing scrolls the target into view by default, which lands the page\n // instantly and discards the scroll above.\n target.focus({ preventScroll: true });\n }\n }\n }\n\n /**\n * Decides the next visibility from the current scroll state and applies it.\n *\n * @param notify - whether a transition announces itself. The reflection\n * `connect()` performs is the current state, not a change.\n *\n * @stimeoRenderRoot\n */\n #evaluate(notify = true): void {\n const y = scrollOffset(this.#scrollSource);\n let nextVisible: boolean;\n if (this.modeValue === \"direction\") {\n // Near the very top, always reveal so a hide-on-scroll header is never\n // stranded off-screen when the page cannot scroll up any further.\n if (y <= this.#offset) {\n nextVisible = true;\n } else if (y === this.#lastScrollY) {\n // No vertical movement carries no direction, so it decides nothing.\n return;\n } else {\n nextVisible = y < this.#lastScrollY; // scrolling up reveals, down hides\n }\n } else {\n nextVisible = y > this.#offset;\n }\n this.#lastScrollY = y;\n this.#setVisible(nextVisible, notify);\n }\n\n /** Applies visibility to the target, syncing `hidden`, `data-state`, `change`. */\n #setVisible(next: boolean, notify: boolean): void {\n if (next === this.#visible) return;\n const focused = !next && this.hasElementTarget ? this.#focusedWithin() : null;\n if (focused) {\n // Hiding the element that holds focus drops it to the document body,\n // stranding a keyboard user mid-interaction (WCAG 2.4.7 / 2.4.11). Hold\n // the hide until it blurs; the state stays what the user can still see.\n this.#pendingHide.deferOnly(focused);\n return;\n }\n this.#visible = next;\n if (this.hasElementTarget) this.elementTarget.hidden = !next;\n this.element.setAttribute(\"data-state\", next ? \"visible\" : \"hidden\");\n if (notify) this.dispatch(\"change\", { detail: { visible: next } });\n }\n\n /**\n * The focus owner inside the target, or `null` when focus is elsewhere.\n *\n * `blur` does not bubble, so the deferral has to ride the focused element\n * itself: waiting on a container that never receives the event would hold the\n * hide forever.\n */\n #focusedWithin(): HTMLElement | null {\n const focused = document.activeElement;\n if (focused instanceof HTMLElement && this.elementTarget.contains(focused)) return focused;\n return null;\n }\n}\n"]}
|
|
@@ -29,8 +29,9 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
29
29
|
* closest tracked section keeps the highlight, so a table of contents never
|
|
30
30
|
* goes blank.
|
|
31
31
|
*
|
|
32
|
-
* `data-action` on the links is optional and opts into
|
|
33
|
-
*
|
|
32
|
+
* `data-action` on the links is optional and opts into
|
|
33
|
+
* {@link ScrollspyController.scrollTo | scrollTo}, which scrolls the nested
|
|
34
|
+
* container instead of bouncing the whole window.
|
|
34
35
|
*
|
|
35
36
|
* `change` dispatches `{ id: string, link: HTMLElement }`.
|
|
36
37
|
*
|
|
@@ -10,6 +10,47 @@ function parseDeclared(raw, parse, fallback) {
|
|
|
10
10
|
return fallback;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
function validSelector(element, raw, fallback) {
|
|
14
|
+
if (raw.length === 0) return fallback;
|
|
15
|
+
return parseDeclared(
|
|
16
|
+
raw,
|
|
17
|
+
(selector) => {
|
|
18
|
+
element.matches(selector);
|
|
19
|
+
return selector;
|
|
20
|
+
},
|
|
21
|
+
fallback
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/utils/frame_coalescer.ts
|
|
26
|
+
var FrameCoalescer = class {
|
|
27
|
+
#frame = null;
|
|
28
|
+
/**
|
|
29
|
+
* Runs `run` on the next frame, unless a frame is already pending — the first
|
|
30
|
+
* request of a burst wins and the rest are dropped. The pending frame is
|
|
31
|
+
* released before `run`, so `run` may request the next one.
|
|
32
|
+
*/
|
|
33
|
+
schedule(run) {
|
|
34
|
+
if (this.#frame !== null) return;
|
|
35
|
+
this.#frame = requestAnimationFrame(() => {
|
|
36
|
+
this.#frame = null;
|
|
37
|
+
run();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Drops the pending frame, and reaches the platform only when there is one.
|
|
42
|
+
*
|
|
43
|
+
* There is no handle value that stands for "nothing pending":
|
|
44
|
+
* `cancelAnimationFrame` takes an `unsigned long`, so a negative placeholder
|
|
45
|
+
* arrives as a large positive number that the same allocator can hand out, and
|
|
46
|
+
* an idle cancel would then drop a frame belonging to someone else.
|
|
47
|
+
*/
|
|
48
|
+
cancel() {
|
|
49
|
+
if (this.#frame === null) return;
|
|
50
|
+
cancelAnimationFrame(this.#frame);
|
|
51
|
+
this.#frame = null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
13
54
|
|
|
14
55
|
// src/utils/intersection_watcher.ts
|
|
15
56
|
function queryRoot(selector) {
|
|
@@ -117,6 +158,15 @@ function prefersReducedMotion() {
|
|
|
117
158
|
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
118
159
|
}
|
|
119
160
|
|
|
161
|
+
// src/utils/scroll_source.ts
|
|
162
|
+
function resolveScrollContainer(selector) {
|
|
163
|
+
const match = selector ? document.querySelector(selector) : null;
|
|
164
|
+
return match instanceof HTMLElement ? match : null;
|
|
165
|
+
}
|
|
166
|
+
function scrollOffset(source) {
|
|
167
|
+
return source === window ? window.scrollY ?? window.pageYOffset ?? 0 : source.scrollTop;
|
|
168
|
+
}
|
|
169
|
+
|
|
120
170
|
// src/controllers/scrollspy_controller.ts
|
|
121
171
|
var ANCHOR_ATTRIBUTES = ["href", "data-href"];
|
|
122
172
|
var ScrollspyController = class extends Controller {
|
|
@@ -163,8 +213,10 @@ var ScrollspyController = class extends Controller {
|
|
|
163
213
|
* rather than from whatever the selector resolves to now.
|
|
164
214
|
*/
|
|
165
215
|
#scrollSource = null;
|
|
166
|
-
/**
|
|
167
|
-
#
|
|
216
|
+
/** Coalesces a scroll burst into one measurement per frame. */
|
|
217
|
+
#frames = new FrameCoalescer();
|
|
218
|
+
/** The validated `rootSelector`, or `""` when the declaration cannot be parsed. */
|
|
219
|
+
#rootSelector = "";
|
|
168
220
|
/** Watches the link targets' anchor attributes for an in-place morph rewrite. */
|
|
169
221
|
#anchorObserver = null;
|
|
170
222
|
/** True while a coalesced observation rebuild is queued; see {@link #scheduleResync}. */
|
|
@@ -181,10 +233,7 @@ var ScrollspyController = class extends Controller {
|
|
|
181
233
|
this.#anchorObserver?.disconnect();
|
|
182
234
|
this.#anchorObserver = null;
|
|
183
235
|
this.#detachScrollListener();
|
|
184
|
-
|
|
185
|
-
cancelAnimationFrame(this.#frame);
|
|
186
|
-
this.#frame = null;
|
|
187
|
-
}
|
|
236
|
+
this.#frames.cancel();
|
|
188
237
|
this.#intersectionStates.clear();
|
|
189
238
|
this.#activeSectionId = "";
|
|
190
239
|
this.#rootElement = null;
|
|
@@ -201,6 +250,7 @@ var ScrollspyController = class extends Controller {
|
|
|
201
250
|
this.#initializeObserver();
|
|
202
251
|
}
|
|
203
252
|
rootSelectorValueChanged() {
|
|
253
|
+
this.#rootSelector = validSelector(this.element, this.rootSelectorValue, "");
|
|
204
254
|
if (!this.#isConnected) return;
|
|
205
255
|
this.#initializeObserver();
|
|
206
256
|
}
|
|
@@ -294,10 +344,10 @@ var ScrollspyController = class extends Controller {
|
|
|
294
344
|
const offset = this.#offset;
|
|
295
345
|
if (rootElement) {
|
|
296
346
|
const containerRect = rootElement.getBoundingClientRect();
|
|
297
|
-
const scrollPosition = rootElement
|
|
347
|
+
const scrollPosition = scrollOffset(rootElement) + (targetRect.top - containerRect.top) - offset;
|
|
298
348
|
rootElement.scrollTo({ top: scrollPosition, behavior });
|
|
299
349
|
} else {
|
|
300
|
-
const scrollPosition = window
|
|
350
|
+
const scrollPosition = scrollOffset(window) + targetRect.top - offset;
|
|
301
351
|
window.scrollTo({ top: scrollPosition, behavior });
|
|
302
352
|
}
|
|
303
353
|
if (this.focusSectionValue) this.#focusSection(targetElement);
|
|
@@ -332,19 +382,14 @@ var ScrollspyController = class extends Controller {
|
|
|
332
382
|
/**
|
|
333
383
|
* Resolves `rootSelector` to a scrollable element.
|
|
334
384
|
*
|
|
335
|
-
* @returns The container, or `null` meaning "spy the viewport" when the
|
|
336
|
-
* is empty, matches nothing, matches a non-HTML element
|
|
337
|
-
*
|
|
338
|
-
*
|
|
385
|
+
* @returns The container, or `null` meaning "spy the viewport" when the
|
|
386
|
+
* declaration is empty, matches nothing, or matches a non-HTML element. A typo
|
|
387
|
+
* reads as empty: the declaration is validated once when it changes, so a
|
|
388
|
+
* selector that cannot be parsed degrades to viewport spying rather than
|
|
389
|
+
* leaving the controller inert.
|
|
339
390
|
*/
|
|
340
391
|
#queryRootElement() {
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
const root = document.querySelector(this.rootSelectorValue);
|
|
344
|
-
return root instanceof HTMLElement ? root : null;
|
|
345
|
-
} catch {
|
|
346
|
-
return null;
|
|
347
|
-
}
|
|
392
|
+
return resolveScrollContainer(this.#rootSelector);
|
|
348
393
|
}
|
|
349
394
|
/**
|
|
350
395
|
* Re-evaluates once per frame while the reader scrolls.
|
|
@@ -359,13 +404,7 @@ var ScrollspyController = class extends Controller {
|
|
|
359
404
|
* end in {@link #evaluateActiveSection} — which measures section rects and the
|
|
360
405
|
* root's top edge at that instant — they converge on the same answer.
|
|
361
406
|
*/
|
|
362
|
-
#onScroll = () =>
|
|
363
|
-
if (this.#frame !== null) return;
|
|
364
|
-
this.#frame = requestAnimationFrame(() => {
|
|
365
|
-
this.#frame = null;
|
|
366
|
-
this.#evaluateActiveSection();
|
|
367
|
-
});
|
|
368
|
-
};
|
|
407
|
+
#onScroll = () => this.#frames.schedule(() => this.#evaluateActiveSection());
|
|
369
408
|
/**
|
|
370
409
|
* Points the `scroll` listener at whatever the reader actually scrolls: the
|
|
371
410
|
* resolved root, or the window when the viewport is spied.
|
|
@@ -383,6 +422,10 @@ var ScrollspyController = class extends Controller {
|
|
|
383
422
|
this.#scrollSource?.removeEventListener("scroll", this.#onScroll);
|
|
384
423
|
this.#scrollSource = null;
|
|
385
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* @stimeoRuntimeOnly `rootMargin` wires the observer this call installs; the active section it
|
|
427
|
+
* republishes is the one already recorded.
|
|
428
|
+
*/
|
|
386
429
|
#initializeObserver() {
|
|
387
430
|
this.#watcher.stop();
|
|
388
431
|
this.#intersectionStates.clear();
|
|
@@ -428,6 +471,8 @@ var ScrollspyController = class extends Controller {
|
|
|
428
471
|
* threshold) against a freshly computed trigger line mixes two moments in
|
|
429
472
|
* time, which would make the result depend on how the reader arrived at a
|
|
430
473
|
* position — a smooth scroll and an instant jump to the same offset disagree.
|
|
474
|
+
*
|
|
475
|
+
* @stimeoRenderRoot
|
|
431
476
|
*/
|
|
432
477
|
#evaluateActiveSection() {
|
|
433
478
|
const rootEl = this.#scrollRoot();
|