stimeo-ui 0.2.0 → 0.3.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 +163 -0
- package/dist/cable/index.d.ts +16 -16
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +3 -2
- package/dist/controllers/accordion_controller.js +10 -0
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +64 -0
- package/dist/controllers/alert_dialog_controller.js +318 -0
- package/dist/controllers/alert_dialog_controller.js.map +1 -0
- package/dist/controllers/breadcrumb_controller.d.ts +97 -10
- package/dist/controllers/breadcrumb_controller.js +225 -13
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +11 -1
- package/dist/controllers/calendar_controller.js +89 -22
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +115 -0
- package/dist/controllers/carousel_controller.js +313 -0
- package/dist/controllers/carousel_controller.js.map +1 -0
- package/dist/controllers/clipboard_controller.d.ts +72 -0
- package/dist/controllers/clipboard_controller.js +144 -0
- package/dist/controllers/clipboard_controller.js.map +1 -0
- package/dist/controllers/collapsible_controller.d.ts +70 -0
- package/dist/controllers/collapsible_controller.js +327 -0
- package/dist/controllers/collapsible_controller.js.map +1 -0
- package/dist/controllers/color_picker_controller.d.ts +87 -0
- package/dist/controllers/color_picker_controller.js +252 -0
- package/dist/controllers/color_picker_controller.js.map +1 -0
- package/dist/controllers/combobox_controller.d.ts +14 -1
- package/dist/controllers/combobox_controller.js +162 -23
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +2 -0
- package/dist/controllers/command_palette_controller.js +194 -17
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +7 -6
- package/dist/controllers/context_menu_controller.js +32 -10
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.js +8 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +72 -0
- package/dist/controllers/currency_input_controller.js +147 -0
- package/dist/controllers/currency_input_controller.js.map +1 -0
- package/dist/controllers/data_grid_controller.d.ts +89 -0
- package/dist/controllers/data_grid_controller.js +246 -0
- package/dist/controllers/data_grid_controller.js.map +1 -0
- package/dist/controllers/date_range_picker_controller.d.ts +88 -0
- package/dist/controllers/date_range_picker_controller.js +441 -0
- package/dist/controllers/date_range_picker_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +57 -0
- package/dist/controllers/dismissible_controller.js +117 -0
- package/dist/controllers/dismissible_controller.js.map +1 -0
- package/dist/controllers/drawer_controller.d.ts +96 -0
- package/dist/controllers/drawer_controller.js +630 -0
- package/dist/controllers/drawer_controller.js.map +1 -0
- package/dist/controllers/dropdown_controller.d.ts +1 -2
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.d.ts +73 -0
- package/dist/controllers/editable_controller.js +169 -0
- package/dist/controllers/editable_controller.js.map +1 -0
- package/dist/controllers/file_dropzone_controller.d.ts +92 -0
- package/dist/controllers/file_dropzone_controller.js +165 -0
- package/dist/controllers/file_dropzone_controller.js.map +1 -0
- package/dist/controllers/filter_controller.d.ts +60 -0
- package/dist/controllers/filter_controller.js +86 -0
- package/dist/controllers/filter_controller.js.map +1 -0
- package/dist/controllers/flash_controller.js +36 -5
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +1 -1
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +1 -1
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +6 -4
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +1 -1
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +67 -19
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +54 -11
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +9 -2
- package/dist/controllers/listbox_controller.js +257 -53
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.js +2 -2
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +54 -0
- package/dist/controllers/masonry_controller.js +142 -0
- package/dist/controllers/masonry_controller.js.map +1 -0
- package/dist/controllers/menu_controller.d.ts +29 -11
- package/dist/controllers/menu_controller.js +104 -17
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +142 -0
- package/dist/controllers/menubar_controller.js +785 -0
- package/dist/controllers/menubar_controller.js.map +1 -0
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +141 -0
- package/dist/controllers/multi_select_controller.js +755 -0
- package/dist/controllers/multi_select_controller.js.map +1 -0
- package/dist/controllers/navigation_menu_controller.d.ts +146 -0
- package/dist/controllers/navigation_menu_controller.js +511 -0
- package/dist/controllers/navigation_menu_controller.js.map +1 -0
- package/dist/controllers/nested_form_controller.d.ts +4 -5
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +1 -1
- package/dist/controllers/number_input_controller.js +7 -0
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +18 -1
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +15 -6
- package/dist/controllers/overflow_indicator_controller.js +246 -27
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +78 -19
- package/dist/controllers/overflow_menu_controller.js +381 -57
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +36 -5
- package/dist/controllers/pagination_controller.js +163 -32
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +49 -0
- package/dist/controllers/password_reveal_controller.js +117 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -0
- package/dist/controllers/persist_controller.js +6 -6
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +2 -3
- package/dist/controllers/pointer_drag_controller.js +9 -1
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +1 -2
- package/dist/controllers/popover_controller.js +2 -2
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +4 -4
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.js +22 -3
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +97 -0
- package/dist/controllers/range_slider_controller.js +192 -0
- package/dist/controllers/range_slider_controller.js.map +1 -0
- package/dist/controllers/rating_controller.js +16 -2
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +52 -0
- package/dist/controllers/read_more_controller.js +238 -0
- package/dist/controllers/read_more_controller.js.map +1 -0
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +3 -1
- package/dist/controllers/resizable_controller.js +65 -1
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.js +17 -2
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js +101 -14
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.d.ts +54 -0
- package/dist/controllers/scroll_restore_controller.js +93 -0
- package/dist/controllers/scroll_restore_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
- package/dist/controllers/scroll_visibility_controller.js +40 -6
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +64 -15
- package/dist/controllers/scrollspy_controller.js +369 -74
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +69 -0
- package/dist/controllers/separator_controller.js +96 -0
- package/dist/controllers/separator_controller.js.map +1 -0
- package/dist/controllers/sidebar_controller.d.ts +107 -0
- package/dist/controllers/sidebar_controller.js +761 -0
- package/dist/controllers/sidebar_controller.js.map +1 -0
- package/dist/controllers/skeleton_controller.js +1 -1
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +11 -1
- package/dist/controllers/slider_controller.js +32 -6
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +3 -1
- package/dist/controllers/sortable_controller.js +34 -3
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.js +1 -1
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +4 -0
- package/dist/controllers/stepper_controller.js +28 -12
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +5 -6
- package/dist/controllers/stick_to_bottom_controller.js +9 -4
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +17 -4
- package/dist/controllers/sticky_observer_controller.js +109 -20
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +2 -2
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.js +1 -0
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +13 -4
- package/dist/controllers/tabs_controller.js +26 -3
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +91 -0
- package/dist/controllers/tags_input_controller.js +295 -0
- package/dist/controllers/tags_input_controller.js.map +1 -0
- package/dist/controllers/theme_controller.d.ts +1 -1
- package/dist/controllers/theme_controller.js +42 -13
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +67 -0
- package/dist/controllers/time_picker_controller.js +231 -0
- package/dist/controllers/time_picker_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +5 -14
- package/dist/controllers/toast_controller.js +40 -14
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.js +23 -2
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.d.ts +53 -13
- package/dist/controllers/toolbar_controller.js +230 -31
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +1 -1
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +14 -8
- package/dist/controllers/transition_controller.js +153 -38
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +135 -0
- package/dist/controllers/tree_view_controller.js +691 -0
- package/dist/controllers/tree_view_controller.js.map +1 -0
- package/dist/index.d.ts +27 -1818
- package/dist/index.js +4256 -915
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +375 -39
- package/dist/inspector/cli.js +228 -14
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +419 -48
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +26 -26
- package/dist/inspector/manifest.json +1054 -127
- package/package.json +5 -6
|
@@ -7,6 +7,7 @@ var IntersectionWatcher = class {
|
|
|
7
7
|
#onEntries;
|
|
8
8
|
#observer = null;
|
|
9
9
|
#active = false;
|
|
10
|
+
#usingPlatformDefaults = false;
|
|
10
11
|
constructor(onEntries) {
|
|
11
12
|
this.#onEntries = onEntries;
|
|
12
13
|
}
|
|
@@ -14,10 +15,22 @@ var IntersectionWatcher = class {
|
|
|
14
15
|
get active() {
|
|
15
16
|
return this.#active;
|
|
16
17
|
}
|
|
18
|
+
/** Whether the live observer discarded configured options after construction failed. */
|
|
19
|
+
get usingPlatformDefaults() {
|
|
20
|
+
return this.#usingPlatformDefaults;
|
|
21
|
+
}
|
|
17
22
|
/**
|
|
18
23
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
24
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
25
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
26
|
+
* If initial construction with the configured options fails, the watcher
|
|
27
|
+
* warns and retries once with the same root and platform defaults.
|
|
28
|
+
*
|
|
29
|
+
* @throws The fallback constructor error if both construction attempts fail,
|
|
30
|
+
* or whatever the platform throws from `observe()`. The exception is passed
|
|
31
|
+
* through unchanged, but the watcher rolls back first: every target observed
|
|
32
|
+
* so far is released and `active` stays `false`, so a caller that retries
|
|
33
|
+
* starts from a clean slate.
|
|
21
34
|
*/
|
|
22
35
|
start(targets, options = {}) {
|
|
23
36
|
this.stop();
|
|
@@ -25,60 +38,140 @@ var IntersectionWatcher = class {
|
|
|
25
38
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
26
39
|
if (list.length === 0) return false;
|
|
27
40
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
(entries) => {
|
|
31
|
-
if (this.#active) this.#onEntries(entries);
|
|
32
|
-
}
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
let observer = null;
|
|
42
|
+
try {
|
|
43
|
+
const onEntries = (entries) => {
|
|
44
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
observer = new IntersectionObserver(onEntries, {
|
|
48
|
+
root,
|
|
49
|
+
rootMargin: options.rootMargin,
|
|
50
|
+
threshold: options.threshold
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.warn(
|
|
54
|
+
"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.",
|
|
55
|
+
error
|
|
56
|
+
);
|
|
57
|
+
observer = new IntersectionObserver(onEntries, { root });
|
|
58
|
+
this.#usingPlatformDefaults = true;
|
|
59
|
+
}
|
|
60
|
+
for (const target of list) observer.observe(target);
|
|
61
|
+
this.#observer = observer;
|
|
62
|
+
this.#active = true;
|
|
63
|
+
return true;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
observer?.disconnect();
|
|
66
|
+
this.#observer = null;
|
|
67
|
+
this.#active = false;
|
|
68
|
+
this.#usingPlatformDefaults = false;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
37
71
|
}
|
|
38
72
|
/**
|
|
39
73
|
* Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
|
|
40
74
|
* only reports *changes*, but `observe()` always reports the present state,
|
|
41
75
|
* so unobserve→observe turns "still intersecting" into a fresh callback.
|
|
76
|
+
*
|
|
77
|
+
* @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
|
|
78
|
+
* first, so it never stays live with a half-rearmed target.
|
|
42
79
|
*/
|
|
43
80
|
rearm(target) {
|
|
44
81
|
if (!this.#observer) return;
|
|
45
|
-
|
|
46
|
-
|
|
82
|
+
try {
|
|
83
|
+
this.#observer.unobserve(target);
|
|
84
|
+
this.#observer.observe(target);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
this.stop();
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
47
89
|
}
|
|
48
90
|
/** Severs the observer; late queued callbacks become no-ops via the guard. */
|
|
49
91
|
stop() {
|
|
50
92
|
this.#active = false;
|
|
51
93
|
this.#observer?.disconnect();
|
|
52
94
|
this.#observer = null;
|
|
95
|
+
this.#usingPlatformDefaults = false;
|
|
53
96
|
}
|
|
54
97
|
};
|
|
55
98
|
|
|
99
|
+
// src/utils/reduced_motion.ts
|
|
100
|
+
function prefersReducedMotion() {
|
|
101
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
// src/controllers/scrollspy_controller.ts
|
|
105
|
+
var ANCHOR_ATTRIBUTES = ["href", "data-href"];
|
|
57
106
|
var ScrollspyController = class extends Controller {
|
|
58
107
|
static targets = ["link"];
|
|
59
108
|
static values = {
|
|
60
109
|
offset: { type: Number, default: 0 },
|
|
61
110
|
rootMargin: { type: String, default: "" },
|
|
62
|
-
rootSelector: { type: String, default: "" }
|
|
111
|
+
rootSelector: { type: String, default: "" },
|
|
112
|
+
focusSection: { type: Boolean, default: false }
|
|
63
113
|
};
|
|
64
114
|
static actions = ["scrollTo"];
|
|
65
115
|
static events = ["change"];
|
|
116
|
+
/**
|
|
117
|
+
* The one offset shared by observation, active-section selection, and
|
|
118
|
+
* scrolling. Stimulus parses a malformed Number Value as `NaN`; degrading it
|
|
119
|
+
* to the declared default keeps every path aligned instead of only repairing
|
|
120
|
+
* the observer margin while selection and scrolling still receive `NaN`.
|
|
121
|
+
*/
|
|
122
|
+
get #offset() {
|
|
123
|
+
return Number.isFinite(this.offsetValue) ? this.offsetValue : 0;
|
|
124
|
+
}
|
|
66
125
|
/** Shared IO plumbing (support guard, active guard, teardown). */
|
|
67
126
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersection(entries));
|
|
68
127
|
#isConnected = false;
|
|
69
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Sections currently tracked, by id. Only the *latest reported* intersection
|
|
130
|
+
* flag is stored — never a coordinate. Positions are re-measured when the
|
|
131
|
+
* active section is evaluated, so a section observed several batches ago is
|
|
132
|
+
* never compared against a trigger line computed now.
|
|
133
|
+
*/
|
|
70
134
|
#intersectionStates = /* @__PURE__ */ new Map();
|
|
71
|
-
/** Current active section ID
|
|
135
|
+
/** Current active section ID; comparing it suppresses duplicate `change` events. */
|
|
72
136
|
#activeSectionId = "";
|
|
137
|
+
/**
|
|
138
|
+
* Scroll root resolved when the observer was (re)built; `null` = viewport.
|
|
139
|
+
* Cached so a click and every intersection batch reuse the element the
|
|
140
|
+
* observer is actually watching instead of re-querying the document.
|
|
141
|
+
*/
|
|
142
|
+
#rootElement = null;
|
|
143
|
+
/**
|
|
144
|
+
* The source the `scroll` listener is currently attached to — the resolved
|
|
145
|
+
* root, or the window while the viewport is spied. `null` means "nothing
|
|
146
|
+
* attached", so teardown always detaches from the source it attached to
|
|
147
|
+
* rather than from whatever the selector resolves to now.
|
|
148
|
+
*/
|
|
149
|
+
#scrollSource = null;
|
|
150
|
+
/** Pending re-evaluation frame; coalesces a scroll burst into one measurement. */
|
|
151
|
+
#frame = null;
|
|
152
|
+
/** Watches the link targets' anchor attributes for an in-place morph rewrite. */
|
|
153
|
+
#anchorObserver = null;
|
|
154
|
+
/** True while a coalesced observation rebuild is queued; see {@link #scheduleResync}. */
|
|
155
|
+
#resyncQueued = false;
|
|
73
156
|
connect() {
|
|
74
157
|
this.#isConnected = true;
|
|
158
|
+
this.#observeAnchorAttributes();
|
|
75
159
|
this.#initializeObserver();
|
|
76
160
|
}
|
|
77
161
|
disconnect() {
|
|
78
162
|
this.#isConnected = false;
|
|
163
|
+
this.#resyncQueued = false;
|
|
79
164
|
this.#watcher.stop();
|
|
165
|
+
this.#anchorObserver?.disconnect();
|
|
166
|
+
this.#anchorObserver = null;
|
|
167
|
+
this.#detachScrollListener();
|
|
168
|
+
if (this.#frame !== null) {
|
|
169
|
+
cancelAnimationFrame(this.#frame);
|
|
170
|
+
this.#frame = null;
|
|
171
|
+
}
|
|
80
172
|
this.#intersectionStates.clear();
|
|
81
173
|
this.#activeSectionId = "";
|
|
174
|
+
this.#rootElement = null;
|
|
82
175
|
}
|
|
83
176
|
/**
|
|
84
177
|
* Re-initializes the observer if the offset or rootMargin values change dynamically.
|
|
@@ -96,8 +189,81 @@ var ScrollspyController = class extends Controller {
|
|
|
96
189
|
this.#initializeObserver();
|
|
97
190
|
}
|
|
98
191
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
192
|
+
* Re-syncs the observation set when a Turbo Stream/morph swaps the table of
|
|
193
|
+
* contents. Stimulus fires these before `connect()` for the links already in
|
|
194
|
+
* the markup, hence the guard: the initial observer is built exactly once, by
|
|
195
|
+
* `connect()`.
|
|
196
|
+
*/
|
|
197
|
+
linkTargetConnected() {
|
|
198
|
+
if (this.#isConnected) this.#scheduleResync();
|
|
199
|
+
}
|
|
200
|
+
linkTargetDisconnected() {
|
|
201
|
+
if (this.#isConnected) this.#scheduleResync();
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Queues **one** observation rebuild for the current mutation batch.
|
|
205
|
+
*
|
|
206
|
+
* A single Turbo morph can append one link, drop another, and rewrite a
|
|
207
|
+
* third's `href`, and those arrive through two independent channels —
|
|
208
|
+
* Stimulus's target callbacks and {@link #anchorObserver}. Each channel just
|
|
209
|
+
* raises the flag and queues a drain; the first drain to run does the work and
|
|
210
|
+
* clears it, so every later drain in the same batch finds nothing to do and
|
|
211
|
+
* the set is rebuilt once instead of three times. `disconnect()` clears the
|
|
212
|
+
* same flag, which is how a queued rebuild is dropped rather than run against
|
|
213
|
+
* a detached controller.
|
|
214
|
+
*/
|
|
215
|
+
#scheduleResync() {
|
|
216
|
+
this.#resyncQueued = true;
|
|
217
|
+
queueMicrotask(this.#drainResync);
|
|
218
|
+
}
|
|
219
|
+
#drainResync = () => {
|
|
220
|
+
if (!this.#resyncQueued) return;
|
|
221
|
+
this.#resyncQueued = false;
|
|
222
|
+
this.#initializeObserver();
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* Watches the link targets' anchor attributes so an in-place rewrite re-syncs.
|
|
226
|
+
*
|
|
227
|
+
* A Turbo 8 morph keeps the element **and** its `data-*-target` marker and
|
|
228
|
+
* only rewrites attributes, so Stimulus fires no target callback: without
|
|
229
|
+
* this, a link re-pointed from `#intro` to `#faq` would keep the controller
|
|
230
|
+
* observing `#intro` for the rest of the page's life. The filter is exactly
|
|
231
|
+
* {@link ANCHOR_ATTRIBUTES}; guarding on `MutationObserver` keeps the
|
|
232
|
+
* controller usable where the API is absent, matching `IntersectionWatcher`'s
|
|
233
|
+
* own support guard.
|
|
234
|
+
*/
|
|
235
|
+
#observeAnchorAttributes() {
|
|
236
|
+
if (typeof MutationObserver !== "undefined") {
|
|
237
|
+
this.#anchorObserver = new MutationObserver(this.#onAnchorMutation);
|
|
238
|
+
this.#anchorObserver.observe(this.element, {
|
|
239
|
+
subtree: true,
|
|
240
|
+
attributes: true,
|
|
241
|
+
attributeFilter: ANCHOR_ATTRIBUTES
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Rebuilds only for a rewrite on a *current* link target. The observer is
|
|
247
|
+
* scoped to this controller's element, but that subtree also holds links the
|
|
248
|
+
* author never marked as targets (a "back to top" anchor, a nested nav), and
|
|
249
|
+
* those anchor nothing here.
|
|
250
|
+
*/
|
|
251
|
+
#onAnchorMutation = (records) => {
|
|
252
|
+
const links = this.linkTargets;
|
|
253
|
+
for (const record of records) {
|
|
254
|
+
if (!links.includes(record.target)) continue;
|
|
255
|
+
this.#scheduleResync();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Scrolls to the section the clicked link anchors, honoring `offset` and any
|
|
261
|
+
* nested scroll container (a plain fragment jump would scroll the window).
|
|
262
|
+
*
|
|
263
|
+
* Honors `prefers-reduced-motion` (WCAG 2.2 **2.3.3**) by forcing an instant
|
|
264
|
+
* jump independently of the consumer's CSS `scroll-behavior`. With
|
|
265
|
+
* `focusSection` enabled it also moves the sequential focus starting point
|
|
266
|
+
* into the destination; the URL fragment is deliberately not touched.
|
|
101
267
|
*/
|
|
102
268
|
scrollTo(event) {
|
|
103
269
|
const link = event.currentTarget;
|
|
@@ -106,109 +272,238 @@ var ScrollspyController = class extends Controller {
|
|
|
106
272
|
event.preventDefault();
|
|
107
273
|
const targetElement = document.getElementById(id);
|
|
108
274
|
if (!targetElement) return;
|
|
109
|
-
const
|
|
275
|
+
const behavior = prefersReducedMotion() ? "instant" : "smooth";
|
|
276
|
+
const rootElement = this.#scrollRoot();
|
|
277
|
+
const targetRect = targetElement.getBoundingClientRect();
|
|
278
|
+
const offset = this.#offset;
|
|
110
279
|
if (rootElement) {
|
|
111
280
|
const containerRect = rootElement.getBoundingClientRect();
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
rootElement.scrollTo({
|
|
115
|
-
top: scrollPosition,
|
|
116
|
-
behavior: "smooth"
|
|
117
|
-
});
|
|
281
|
+
const scrollPosition = rootElement.scrollTop + (targetRect.top - containerRect.top) - offset;
|
|
282
|
+
rootElement.scrollTo({ top: scrollPosition, behavior });
|
|
118
283
|
} else {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
window.scrollTo({
|
|
122
|
-
top: scrollPosition,
|
|
123
|
-
behavior: "smooth"
|
|
124
|
-
});
|
|
284
|
+
const scrollPosition = window.scrollY + targetRect.top - offset;
|
|
285
|
+
window.scrollTo({ top: scrollPosition, behavior });
|
|
125
286
|
}
|
|
287
|
+
if (this.focusSectionValue) this.#focusSection(targetElement);
|
|
126
288
|
}
|
|
127
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Moves the sequential focus starting point into the section `scrollTo` just
|
|
291
|
+
* jumped to, so the next Tab continues *inside* the destination instead of
|
|
292
|
+
* resuming in the table of contents (`preventDefault()` alone would leave the
|
|
293
|
+
* starting point on the link). Opt-in through `focusSection`, because moving
|
|
294
|
+
* focus is a decision only the consuming page can make.
|
|
295
|
+
*
|
|
296
|
+
* `tabindex="-1"` is established only when the section is not already
|
|
297
|
+
* focusable and is never removed, so an author-owned tabindex is left alone.
|
|
298
|
+
* `preventScroll` keeps the focus call from cancelling the smooth scroll
|
|
299
|
+
* started just above.
|
|
300
|
+
*/
|
|
301
|
+
#focusSection(section) {
|
|
302
|
+
if (!section.hasAttribute("tabindex")) section.setAttribute("tabindex", "-1");
|
|
303
|
+
section.focus({ preventScroll: true });
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The cached scroll root, re-resolved when the cached element has left the
|
|
307
|
+
* document (a Turbo morph replaced the container) so `scrollTo` never
|
|
308
|
+
* scrolls a detached node.
|
|
309
|
+
*/
|
|
310
|
+
#scrollRoot() {
|
|
311
|
+
if (this.#rootElement && !this.#rootElement.isConnected) {
|
|
312
|
+
this.#rootElement = this.#queryRootElement();
|
|
313
|
+
}
|
|
314
|
+
return this.#rootElement;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Resolves `rootSelector` to a scrollable element.
|
|
318
|
+
*
|
|
319
|
+
* @returns The container, or `null` meaning "spy the viewport" when the value
|
|
320
|
+
* is empty, matches nothing, matches a non-HTML element (an SVG node is not a
|
|
321
|
+
* scroll container), or is not a valid selector — a typo in a data attribute
|
|
322
|
+
* must degrade to viewport spying, not leave the controller inert.
|
|
323
|
+
*/
|
|
324
|
+
#queryRootElement() {
|
|
128
325
|
if (!this.rootSelectorValue) return null;
|
|
129
|
-
|
|
326
|
+
try {
|
|
327
|
+
const root = document.querySelector(this.rootSelectorValue);
|
|
328
|
+
return root instanceof HTMLElement ? root : null;
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Re-evaluates once per frame while the reader scrolls.
|
|
335
|
+
*
|
|
336
|
+
* `IntersectionObserver` reports **threshold crossings**, not positions, so a
|
|
337
|
+
* reader moving inside one long section — or across a gap wider than the
|
|
338
|
+
* observation band — produces no batch at all and the highlight would stay
|
|
339
|
+
* frozen at whatever the last crossing decided. That would make the current
|
|
340
|
+
* location depend on the *route* to a position instead of the position: a
|
|
341
|
+
* stepwise scroll crosses thresholds an instant jump to the same offset never
|
|
342
|
+
* does. Listening to the scroll source closes the gap, and because both paths
|
|
343
|
+
* end in {@link #evaluateActiveSection} — which measures section rects and the
|
|
344
|
+
* root's top edge at that instant — they converge on the same answer.
|
|
345
|
+
*/
|
|
346
|
+
#onScroll = () => {
|
|
347
|
+
if (this.#frame !== null) return;
|
|
348
|
+
this.#frame = requestAnimationFrame(() => {
|
|
349
|
+
this.#frame = null;
|
|
350
|
+
this.#evaluateActiveSection();
|
|
351
|
+
});
|
|
352
|
+
};
|
|
353
|
+
/**
|
|
354
|
+
* Points the `scroll` listener at whatever the reader actually scrolls: the
|
|
355
|
+
* resolved root, or the window when the viewport is spied.
|
|
356
|
+
*
|
|
357
|
+
* The detach is unconditional, so rebuilding the observation set (a Value
|
|
358
|
+
* change, a morph) **moves** the listener rather than stacking a second one on
|
|
359
|
+
* a container the reader has stopped scrolling.
|
|
360
|
+
*/
|
|
361
|
+
#syncScrollListener() {
|
|
362
|
+
this.#detachScrollListener();
|
|
363
|
+
this.#scrollSource = this.#rootElement ?? window;
|
|
364
|
+
this.#scrollSource.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
365
|
+
}
|
|
366
|
+
#detachScrollListener() {
|
|
367
|
+
this.#scrollSource?.removeEventListener("scroll", this.#onScroll);
|
|
368
|
+
this.#scrollSource = null;
|
|
130
369
|
}
|
|
131
370
|
#initializeObserver() {
|
|
132
371
|
this.#watcher.stop();
|
|
133
372
|
this.#intersectionStates.clear();
|
|
134
|
-
this.#activeSectionId =
|
|
373
|
+
this.#activeSectionId = this.#activeSectionId || this.#activeIdFromDom();
|
|
374
|
+
this.#rootElement = this.#queryRootElement();
|
|
375
|
+
this.#syncScrollListener();
|
|
135
376
|
if (this.linkTargets.length === 0) return;
|
|
136
|
-
const margin = this.rootMarginValue ||
|
|
377
|
+
const margin = this.rootMarginValue || `${-this.#offset}px 0px -80% 0px`;
|
|
137
378
|
const sections = [];
|
|
138
379
|
for (const link of this.linkTargets) {
|
|
139
380
|
const id = this.#getAnchorId(link);
|
|
140
381
|
if (!id) continue;
|
|
141
382
|
const section = document.getElementById(id);
|
|
142
|
-
if (section) sections.push(section);
|
|
383
|
+
if (section && !sections.includes(section)) sections.push(section);
|
|
143
384
|
}
|
|
144
385
|
this.#watcher.start(sections, {
|
|
145
|
-
root: this.#
|
|
386
|
+
root: this.#rootElement,
|
|
146
387
|
rootMargin: margin,
|
|
147
388
|
threshold: [0, 0.2, 0.4, 0.6, 0.8, 1]
|
|
148
389
|
// Multiple thresholds handle large sections safely
|
|
149
390
|
});
|
|
391
|
+
this.#syncActiveStates(false);
|
|
150
392
|
}
|
|
151
393
|
#onIntersection = (entries) => {
|
|
152
|
-
|
|
394
|
+
const isDetached = !this.#isConnected;
|
|
395
|
+
if (isDetached) return;
|
|
153
396
|
for (const entry of entries) {
|
|
154
|
-
const
|
|
155
|
-
if (!
|
|
156
|
-
this.#intersectionStates.set(
|
|
157
|
-
|
|
158
|
-
|
|
397
|
+
const sectionId = entry.target.id;
|
|
398
|
+
if (!sectionId) continue;
|
|
399
|
+
this.#intersectionStates.set(sectionId, {
|
|
400
|
+
element: entry.target,
|
|
401
|
+
isIntersecting: entry.isIntersecting
|
|
159
402
|
});
|
|
160
403
|
}
|
|
161
404
|
this.#evaluateActiveSection();
|
|
162
405
|
};
|
|
406
|
+
/**
|
|
407
|
+
* Picks the section closest to the trigger line and publishes it.
|
|
408
|
+
*
|
|
409
|
+
* Every coordinate is read **now**: the section rects *and* the root's top
|
|
410
|
+
* edge share one measurement instant. Comparing an entry's recorded
|
|
411
|
+
* `boundingClientRect.top` (captured whenever that section last crossed a
|
|
412
|
+
* threshold) against a freshly computed trigger line mixes two moments in
|
|
413
|
+
* time, which would make the result depend on how the reader arrived at a
|
|
414
|
+
* position — a smooth scroll and an instant jump to the same offset disagree.
|
|
415
|
+
*/
|
|
163
416
|
#evaluateActiveSection() {
|
|
164
|
-
const rootEl = this.#
|
|
165
|
-
const triggerLine = (rootEl ? rootEl.getBoundingClientRect().top : 0) + this
|
|
166
|
-
let
|
|
167
|
-
let
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
417
|
+
const rootEl = this.#scrollRoot();
|
|
418
|
+
const triggerLine = (rootEl ? rootEl.getBoundingClientRect().top : 0) + this.#offset;
|
|
419
|
+
let intersectingId = "";
|
|
420
|
+
let intersectingDistance = Number.POSITIVE_INFINITY;
|
|
421
|
+
let trackedId = "";
|
|
422
|
+
let trackedDistance = Number.POSITIVE_INFINITY;
|
|
423
|
+
for (const [id, state] of this.#intersectionStates) {
|
|
424
|
+
const rect = state.element.getBoundingClientRect();
|
|
425
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
426
|
+
const distance = Math.abs(rect.top - triggerLine);
|
|
427
|
+
if (distance < trackedDistance) {
|
|
428
|
+
trackedDistance = distance;
|
|
429
|
+
trackedId = id;
|
|
175
430
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
let absoluteClosestTop = Number.MAX_VALUE;
|
|
180
|
-
for (const [id, state] of this.#intersectionStates.entries()) {
|
|
181
|
-
const distance = Math.abs(state.top - triggerLine);
|
|
182
|
-
if (distance < absoluteClosestTop) {
|
|
183
|
-
absoluteClosestTop = distance;
|
|
184
|
-
absoluteClosestId = id;
|
|
185
|
-
}
|
|
431
|
+
if (state.isIntersecting && distance < intersectingDistance) {
|
|
432
|
+
intersectingDistance = distance;
|
|
433
|
+
intersectingId = id;
|
|
186
434
|
}
|
|
187
|
-
bestId = absoluteClosestId;
|
|
188
435
|
}
|
|
436
|
+
const bestId = intersectingId || trackedId;
|
|
189
437
|
if (bestId && bestId !== this.#activeSectionId) {
|
|
190
438
|
this.#activeSectionId = bestId;
|
|
191
|
-
this.#syncActiveStates();
|
|
439
|
+
this.#syncActiveStates(true);
|
|
192
440
|
}
|
|
193
441
|
}
|
|
194
|
-
|
|
195
|
-
|
|
442
|
+
/**
|
|
443
|
+
* Writes `aria-current` across the current link set, optionally announcing.
|
|
444
|
+
*
|
|
445
|
+
* The two halves are separate because they answer different questions.
|
|
446
|
+
* *Attributes* must be idempotently re-established whenever the link set
|
|
447
|
+
* changes, even though the reader has not moved — otherwise a link that
|
|
448
|
+
* appears (or is re-anchored) while its section is already current never gets
|
|
449
|
+
* marked. *The `change` event* announces that the reader moved, so it fires
|
|
450
|
+
* only from the evaluation path; re-publishing over a new link set is not
|
|
451
|
+
* news, and dispatching there would make a rebuild look like navigation.
|
|
452
|
+
*
|
|
453
|
+
* @param announce Whether this sync represents a change of current section.
|
|
454
|
+
*/
|
|
455
|
+
#syncActiveStates(announce) {
|
|
456
|
+
const activeLinks = this.linkTargets.filter(
|
|
457
|
+
(link) => this.#getAnchorId(link) === this.#activeSectionId
|
|
458
|
+
);
|
|
196
459
|
for (const link of this.linkTargets) {
|
|
197
|
-
|
|
198
|
-
if (isActive) {
|
|
460
|
+
if (activeLinks.includes(link)) {
|
|
199
461
|
link.setAttribute("aria-current", "location");
|
|
200
|
-
} else {
|
|
462
|
+
} else if (link.getAttribute("aria-current") === "location") {
|
|
201
463
|
link.removeAttribute("aria-current");
|
|
202
464
|
}
|
|
203
465
|
}
|
|
204
|
-
if (
|
|
205
|
-
|
|
466
|
+
if (!announce) return;
|
|
467
|
+
const primaryLink = activeLinks[0];
|
|
468
|
+
if (primaryLink) {
|
|
469
|
+
this.dispatch("change", { detail: { id: this.#activeSectionId, link: primaryLink } });
|
|
206
470
|
}
|
|
207
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* The current location already encoded in the DOM, i.e. the section anchored
|
|
474
|
+
* by the first link carrying this controller's `aria-current="location"`.
|
|
475
|
+
* Empty when no link claims it (a genuinely fresh render).
|
|
476
|
+
*/
|
|
477
|
+
#activeIdFromDom() {
|
|
478
|
+
for (const link of this.linkTargets) {
|
|
479
|
+
if (link.getAttribute("aria-current") !== "location") continue;
|
|
480
|
+
const id = this.#getAnchorId(link);
|
|
481
|
+
if (id) return id;
|
|
482
|
+
}
|
|
483
|
+
return "";
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* The section id a link anchors, resolved in a fixed order:
|
|
487
|
+
*
|
|
488
|
+
* 1. `href`, when it is a non-empty same-document fragment — the contract.
|
|
489
|
+
* 2. otherwise `data-href`'s fragment — the fallback for a link whose `href`
|
|
490
|
+
* must stay a real URL (a server-rendered permalink) or that is not an
|
|
491
|
+
* `<a>` at all.
|
|
492
|
+
* 3. otherwise `null`: the link anchors nothing here and is not observed.
|
|
493
|
+
*
|
|
494
|
+
* Step 2 is reached whenever `href` yields nothing usable — absent, `"#"`, or
|
|
495
|
+
* a real URL — which is the whole point of the fallback: `href="/guide/usage"`
|
|
496
|
+
* with `data-href="#usage"` is a permalink that also spies, and picking the
|
|
497
|
+
* first *present* attribute instead of the first *usable* one would silently
|
|
498
|
+
* exclude exactly that markup. When both are valid fragments `href` wins.
|
|
499
|
+
*/
|
|
208
500
|
#getAnchorId(link) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
501
|
+
return this.#fragmentId(link.getAttribute("href")) ?? this.#fragmentId(link.getAttribute("data-href"));
|
|
502
|
+
}
|
|
503
|
+
/** The id in a `#fragment` value; `null` for absent, empty, or non-fragment. */
|
|
504
|
+
#fragmentId(value) {
|
|
505
|
+
if (!value?.startsWith("#")) return null;
|
|
506
|
+
return value.substring(1) || null;
|
|
212
507
|
}
|
|
213
508
|
};
|
|
214
509
|
|