stimeo-ui 0.2.1 → 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 +104 -0
- package/dist/cable/index.d.ts +16 -16
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.js +10 -0
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- 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 +16 -1
- package/dist/controllers/carousel_controller.js +47 -6
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.js +2 -2
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +12 -1
- package/dist/controllers/color_picker_controller.js +46 -7
- package/dist/controllers/color_picker_controller.js.map +1 -1
- 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.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +19 -1
- package/dist/controllers/data_grid_controller.js +82 -4
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +1 -0
- package/dist/controllers/date_range_picker_controller.js +27 -3
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +1 -2
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.js +1 -0
- package/dist/controllers/editable_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.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 +36 -11
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +31 -10
- 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/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 +91 -11
- package/dist/controllers/menubar_controller.js +415 -63
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +31 -5
- package/dist/controllers/multi_select_controller.js +312 -29
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.d.ts +65 -18
- package/dist/controllers/navigation_menu_controller.js +154 -27
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- 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 +3 -2
- package/dist/controllers/overflow_indicator_controller.js +81 -13
- 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/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 +14 -4
- package/dist/controllers/range_slider_controller.js +32 -6
- package/dist/controllers/range_slider_controller.js.map +1 -1
- package/dist/controllers/rating_controller.js +16 -2
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.js +63 -19
- package/dist/controllers/read_more_controller.js.map +1 -1
- 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 +86 -12
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.js +1 -1
- package/dist/controllers/scroll_restore_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
- package/dist/controllers/scroll_visibility_controller.js +33 -3
- 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 +346 -73
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +20 -5
- package/dist/controllers/separator_controller.js +9 -0
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +1 -1
- package/dist/controllers/sidebar_controller.js.map +1 -1
- 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/stick_to_bottom_controller.d.ts +5 -6
- package/dist/controllers/stick_to_bottom_controller.js +2 -1
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js +32 -11
- 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 +11 -3
- package/dist/controllers/tabs_controller.js +26 -3
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.js +22 -2
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +1 -1
- package/dist/controllers/theme_controller.js +22 -3
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.js +20 -1
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +4 -5
- package/dist/controllers/toast_controller.js +4 -5
- 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.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +87 -6
- package/dist/controllers/tree_view_controller.js +467 -51
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.js +3514 -689
- 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 +24 -24
- package/dist/inspector/manifest.json +1040 -125
- 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,15 +15,22 @@ var IntersectionWatcher = class {
|
|
|
14
15
|
get active() {
|
|
15
16
|
return this.#active;
|
|
16
17
|
}
|
|
18
|
+
/** Whether the live observer discarded configured options after construction failed. */
|
|
19
|
+
get usingPlatformDefaults() {
|
|
20
|
+
return this.#usingPlatformDefaults;
|
|
21
|
+
}
|
|
17
22
|
/**
|
|
18
23
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
24
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
25
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
26
|
+
* If initial construction with the configured options fails, the watcher
|
|
27
|
+
* warns and retries once with the same root and platform defaults.
|
|
21
28
|
*
|
|
22
|
-
* @throws
|
|
23
|
-
* or
|
|
24
|
-
* the watcher rolls back first: every target observed
|
|
25
|
-
* `active` stays `false`, so a caller that retries
|
|
29
|
+
* @throws The fallback constructor error if both construction attempts fail,
|
|
30
|
+
* or whatever the platform throws from `observe()`. The exception is passed
|
|
31
|
+
* through unchanged, but the watcher rolls back first: every target observed
|
|
32
|
+
* so far is released and `active` stays `false`, so a caller that retries
|
|
33
|
+
* starts from a clean slate.
|
|
26
34
|
*/
|
|
27
35
|
start(targets, options = {}) {
|
|
28
36
|
this.stop();
|
|
@@ -32,12 +40,23 @@ var IntersectionWatcher = class {
|
|
|
32
40
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
33
41
|
let observer = null;
|
|
34
42
|
try {
|
|
35
|
-
|
|
36
|
-
(entries)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
const onEntries = (entries) => {
|
|
44
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
observer = new IntersectionObserver(onEntries, {
|
|
48
|
+
root,
|
|
49
|
+
rootMargin: options.rootMargin,
|
|
50
|
+
threshold: options.threshold
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.warn(
|
|
54
|
+
"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.",
|
|
55
|
+
error
|
|
56
|
+
);
|
|
57
|
+
observer = new IntersectionObserver(onEntries, { root });
|
|
58
|
+
this.#usingPlatformDefaults = true;
|
|
59
|
+
}
|
|
41
60
|
for (const target of list) observer.observe(target);
|
|
42
61
|
this.#observer = observer;
|
|
43
62
|
this.#active = true;
|
|
@@ -46,6 +65,7 @@ var IntersectionWatcher = class {
|
|
|
46
65
|
observer?.disconnect();
|
|
47
66
|
this.#observer = null;
|
|
48
67
|
this.#active = false;
|
|
68
|
+
this.#usingPlatformDefaults = false;
|
|
49
69
|
throw error;
|
|
50
70
|
}
|
|
51
71
|
}
|
|
@@ -72,35 +92,86 @@ var IntersectionWatcher = class {
|
|
|
72
92
|
this.#active = false;
|
|
73
93
|
this.#observer?.disconnect();
|
|
74
94
|
this.#observer = null;
|
|
95
|
+
this.#usingPlatformDefaults = false;
|
|
75
96
|
}
|
|
76
97
|
};
|
|
77
98
|
|
|
99
|
+
// src/utils/reduced_motion.ts
|
|
100
|
+
function prefersReducedMotion() {
|
|
101
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
102
|
+
}
|
|
103
|
+
|
|
78
104
|
// src/controllers/scrollspy_controller.ts
|
|
105
|
+
var ANCHOR_ATTRIBUTES = ["href", "data-href"];
|
|
79
106
|
var ScrollspyController = class extends Controller {
|
|
80
107
|
static targets = ["link"];
|
|
81
108
|
static values = {
|
|
82
109
|
offset: { type: Number, default: 0 },
|
|
83
110
|
rootMargin: { type: String, default: "" },
|
|
84
|
-
rootSelector: { type: String, default: "" }
|
|
111
|
+
rootSelector: { type: String, default: "" },
|
|
112
|
+
focusSection: { type: Boolean, default: false }
|
|
85
113
|
};
|
|
86
114
|
static actions = ["scrollTo"];
|
|
87
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
|
+
}
|
|
88
125
|
/** Shared IO plumbing (support guard, active guard, teardown). */
|
|
89
126
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersection(entries));
|
|
90
127
|
#isConnected = false;
|
|
91
|
-
/**
|
|
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
|
+
*/
|
|
92
134
|
#intersectionStates = /* @__PURE__ */ new Map();
|
|
93
|
-
/** Current active section ID
|
|
135
|
+
/** Current active section ID; comparing it suppresses duplicate `change` events. */
|
|
94
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;
|
|
95
156
|
connect() {
|
|
96
157
|
this.#isConnected = true;
|
|
158
|
+
this.#observeAnchorAttributes();
|
|
97
159
|
this.#initializeObserver();
|
|
98
160
|
}
|
|
99
161
|
disconnect() {
|
|
100
162
|
this.#isConnected = false;
|
|
163
|
+
this.#resyncQueued = false;
|
|
101
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
|
+
}
|
|
102
172
|
this.#intersectionStates.clear();
|
|
103
173
|
this.#activeSectionId = "";
|
|
174
|
+
this.#rootElement = null;
|
|
104
175
|
}
|
|
105
176
|
/**
|
|
106
177
|
* Re-initializes the observer if the offset or rootMargin values change dynamically.
|
|
@@ -118,8 +189,81 @@ var ScrollspyController = class extends Controller {
|
|
|
118
189
|
this.#initializeObserver();
|
|
119
190
|
}
|
|
120
191
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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.
|
|
123
267
|
*/
|
|
124
268
|
scrollTo(event) {
|
|
125
269
|
const link = event.currentTarget;
|
|
@@ -128,109 +272,238 @@ var ScrollspyController = class extends Controller {
|
|
|
128
272
|
event.preventDefault();
|
|
129
273
|
const targetElement = document.getElementById(id);
|
|
130
274
|
if (!targetElement) return;
|
|
131
|
-
const
|
|
275
|
+
const behavior = prefersReducedMotion() ? "instant" : "smooth";
|
|
276
|
+
const rootElement = this.#scrollRoot();
|
|
277
|
+
const targetRect = targetElement.getBoundingClientRect();
|
|
278
|
+
const offset = this.#offset;
|
|
132
279
|
if (rootElement) {
|
|
133
280
|
const containerRect = rootElement.getBoundingClientRect();
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
rootElement.scrollTo({
|
|
137
|
-
top: scrollPosition,
|
|
138
|
-
behavior: "smooth"
|
|
139
|
-
});
|
|
281
|
+
const scrollPosition = rootElement.scrollTop + (targetRect.top - containerRect.top) - offset;
|
|
282
|
+
rootElement.scrollTo({ top: scrollPosition, behavior });
|
|
140
283
|
} else {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
window.scrollTo({
|
|
144
|
-
top: scrollPosition,
|
|
145
|
-
behavior: "smooth"
|
|
146
|
-
});
|
|
284
|
+
const scrollPosition = window.scrollY + targetRect.top - offset;
|
|
285
|
+
window.scrollTo({ top: scrollPosition, behavior });
|
|
147
286
|
}
|
|
287
|
+
if (this.focusSectionValue) this.#focusSection(targetElement);
|
|
148
288
|
}
|
|
149
|
-
|
|
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() {
|
|
150
325
|
if (!this.rootSelectorValue) return null;
|
|
151
|
-
|
|
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;
|
|
152
369
|
}
|
|
153
370
|
#initializeObserver() {
|
|
154
371
|
this.#watcher.stop();
|
|
155
372
|
this.#intersectionStates.clear();
|
|
156
|
-
this.#activeSectionId =
|
|
373
|
+
this.#activeSectionId = this.#activeSectionId || this.#activeIdFromDom();
|
|
374
|
+
this.#rootElement = this.#queryRootElement();
|
|
375
|
+
this.#syncScrollListener();
|
|
157
376
|
if (this.linkTargets.length === 0) return;
|
|
158
|
-
const margin = this.rootMarginValue ||
|
|
377
|
+
const margin = this.rootMarginValue || `${-this.#offset}px 0px -80% 0px`;
|
|
159
378
|
const sections = [];
|
|
160
379
|
for (const link of this.linkTargets) {
|
|
161
380
|
const id = this.#getAnchorId(link);
|
|
162
381
|
if (!id) continue;
|
|
163
382
|
const section = document.getElementById(id);
|
|
164
|
-
if (section) sections.push(section);
|
|
383
|
+
if (section && !sections.includes(section)) sections.push(section);
|
|
165
384
|
}
|
|
166
385
|
this.#watcher.start(sections, {
|
|
167
|
-
root: this.#
|
|
386
|
+
root: this.#rootElement,
|
|
168
387
|
rootMargin: margin,
|
|
169
388
|
threshold: [0, 0.2, 0.4, 0.6, 0.8, 1]
|
|
170
389
|
// Multiple thresholds handle large sections safely
|
|
171
390
|
});
|
|
391
|
+
this.#syncActiveStates(false);
|
|
172
392
|
}
|
|
173
393
|
#onIntersection = (entries) => {
|
|
174
|
-
|
|
394
|
+
const isDetached = !this.#isConnected;
|
|
395
|
+
if (isDetached) return;
|
|
175
396
|
for (const entry of entries) {
|
|
176
|
-
const
|
|
177
|
-
if (!
|
|
178
|
-
this.#intersectionStates.set(
|
|
179
|
-
|
|
180
|
-
|
|
397
|
+
const sectionId = entry.target.id;
|
|
398
|
+
if (!sectionId) continue;
|
|
399
|
+
this.#intersectionStates.set(sectionId, {
|
|
400
|
+
element: entry.target,
|
|
401
|
+
isIntersecting: entry.isIntersecting
|
|
181
402
|
});
|
|
182
403
|
}
|
|
183
404
|
this.#evaluateActiveSection();
|
|
184
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
|
+
*/
|
|
185
416
|
#evaluateActiveSection() {
|
|
186
|
-
const rootEl = this.#
|
|
187
|
-
const triggerLine = (rootEl ? rootEl.getBoundingClientRect().top : 0) + this
|
|
188
|
-
let
|
|
189
|
-
let
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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;
|
|
197
430
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
let absoluteClosestTop = Number.MAX_VALUE;
|
|
202
|
-
for (const [id, state] of this.#intersectionStates.entries()) {
|
|
203
|
-
const distance = Math.abs(state.top - triggerLine);
|
|
204
|
-
if (distance < absoluteClosestTop) {
|
|
205
|
-
absoluteClosestTop = distance;
|
|
206
|
-
absoluteClosestId = id;
|
|
207
|
-
}
|
|
431
|
+
if (state.isIntersecting && distance < intersectingDistance) {
|
|
432
|
+
intersectingDistance = distance;
|
|
433
|
+
intersectingId = id;
|
|
208
434
|
}
|
|
209
|
-
bestId = absoluteClosestId;
|
|
210
435
|
}
|
|
436
|
+
const bestId = intersectingId || trackedId;
|
|
211
437
|
if (bestId && bestId !== this.#activeSectionId) {
|
|
212
438
|
this.#activeSectionId = bestId;
|
|
213
|
-
this.#syncActiveStates();
|
|
439
|
+
this.#syncActiveStates(true);
|
|
214
440
|
}
|
|
215
441
|
}
|
|
216
|
-
|
|
217
|
-
|
|
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
|
+
);
|
|
218
459
|
for (const link of this.linkTargets) {
|
|
219
|
-
|
|
220
|
-
if (isActive) {
|
|
460
|
+
if (activeLinks.includes(link)) {
|
|
221
461
|
link.setAttribute("aria-current", "location");
|
|
222
|
-
} else {
|
|
462
|
+
} else if (link.getAttribute("aria-current") === "location") {
|
|
223
463
|
link.removeAttribute("aria-current");
|
|
224
464
|
}
|
|
225
465
|
}
|
|
226
|
-
if (
|
|
227
|
-
|
|
466
|
+
if (!announce) return;
|
|
467
|
+
const primaryLink = activeLinks[0];
|
|
468
|
+
if (primaryLink) {
|
|
469
|
+
this.dispatch("change", { detail: { id: this.#activeSectionId, link: primaryLink } });
|
|
470
|
+
}
|
|
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;
|
|
228
482
|
}
|
|
483
|
+
return "";
|
|
229
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
|
+
*/
|
|
230
500
|
#getAnchorId(link) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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;
|
|
234
507
|
}
|
|
235
508
|
};
|
|
236
509
|
|