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.
Files changed (227) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/cable/index.d.ts +16 -16
  3. package/dist/cable/index.js.map +1 -1
  4. package/dist/controllers/accordion_controller.d.ts +3 -2
  5. package/dist/controllers/accordion_controller.js +10 -0
  6. package/dist/controllers/accordion_controller.js.map +1 -1
  7. package/dist/controllers/alert_dialog_controller.d.ts +64 -0
  8. package/dist/controllers/alert_dialog_controller.js +318 -0
  9. package/dist/controllers/alert_dialog_controller.js.map +1 -0
  10. package/dist/controllers/breadcrumb_controller.d.ts +97 -10
  11. package/dist/controllers/breadcrumb_controller.js +225 -13
  12. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  13. package/dist/controllers/calendar_controller.d.ts +11 -1
  14. package/dist/controllers/calendar_controller.js +89 -22
  15. package/dist/controllers/calendar_controller.js.map +1 -1
  16. package/dist/controllers/carousel_controller.d.ts +115 -0
  17. package/dist/controllers/carousel_controller.js +313 -0
  18. package/dist/controllers/carousel_controller.js.map +1 -0
  19. package/dist/controllers/clipboard_controller.d.ts +72 -0
  20. package/dist/controllers/clipboard_controller.js +144 -0
  21. package/dist/controllers/clipboard_controller.js.map +1 -0
  22. package/dist/controllers/collapsible_controller.d.ts +70 -0
  23. package/dist/controllers/collapsible_controller.js +327 -0
  24. package/dist/controllers/collapsible_controller.js.map +1 -0
  25. package/dist/controllers/color_picker_controller.d.ts +87 -0
  26. package/dist/controllers/color_picker_controller.js +252 -0
  27. package/dist/controllers/color_picker_controller.js.map +1 -0
  28. package/dist/controllers/combobox_controller.d.ts +14 -1
  29. package/dist/controllers/combobox_controller.js +162 -23
  30. package/dist/controllers/combobox_controller.js.map +1 -1
  31. package/dist/controllers/command_palette_controller.d.ts +2 -0
  32. package/dist/controllers/command_palette_controller.js +194 -17
  33. package/dist/controllers/command_palette_controller.js.map +1 -1
  34. package/dist/controllers/confirm_controller.js.map +1 -1
  35. package/dist/controllers/context_menu_controller.d.ts +7 -6
  36. package/dist/controllers/context_menu_controller.js +32 -10
  37. package/dist/controllers/context_menu_controller.js.map +1 -1
  38. package/dist/controllers/count_up_controller.js +8 -1
  39. package/dist/controllers/count_up_controller.js.map +1 -1
  40. package/dist/controllers/currency_input_controller.d.ts +72 -0
  41. package/dist/controllers/currency_input_controller.js +147 -0
  42. package/dist/controllers/currency_input_controller.js.map +1 -0
  43. package/dist/controllers/data_grid_controller.d.ts +89 -0
  44. package/dist/controllers/data_grid_controller.js +246 -0
  45. package/dist/controllers/data_grid_controller.js.map +1 -0
  46. package/dist/controllers/date_range_picker_controller.d.ts +88 -0
  47. package/dist/controllers/date_range_picker_controller.js +441 -0
  48. package/dist/controllers/date_range_picker_controller.js.map +1 -0
  49. package/dist/controllers/dialog_controller.js.map +1 -1
  50. package/dist/controllers/dismissible_controller.d.ts +57 -0
  51. package/dist/controllers/dismissible_controller.js +117 -0
  52. package/dist/controllers/dismissible_controller.js.map +1 -0
  53. package/dist/controllers/drawer_controller.d.ts +96 -0
  54. package/dist/controllers/drawer_controller.js +630 -0
  55. package/dist/controllers/drawer_controller.js.map +1 -0
  56. package/dist/controllers/dropdown_controller.d.ts +1 -2
  57. package/dist/controllers/dropdown_controller.js.map +1 -1
  58. package/dist/controllers/editable_controller.d.ts +73 -0
  59. package/dist/controllers/editable_controller.js +169 -0
  60. package/dist/controllers/editable_controller.js.map +1 -0
  61. package/dist/controllers/file_dropzone_controller.d.ts +92 -0
  62. package/dist/controllers/file_dropzone_controller.js +165 -0
  63. package/dist/controllers/file_dropzone_controller.js.map +1 -0
  64. package/dist/controllers/filter_controller.d.ts +60 -0
  65. package/dist/controllers/filter_controller.js +86 -0
  66. package/dist/controllers/filter_controller.js.map +1 -0
  67. package/dist/controllers/flash_controller.js +36 -5
  68. package/dist/controllers/flash_controller.js.map +1 -1
  69. package/dist/controllers/focus_controller.d.ts +1 -1
  70. package/dist/controllers/focus_controller.js.map +1 -1
  71. package/dist/controllers/form_field_controller.js.map +1 -1
  72. package/dist/controllers/form_validation_controller.js +1 -1
  73. package/dist/controllers/form_validation_controller.js.map +1 -1
  74. package/dist/controllers/highlight_controller.js +6 -4
  75. package/dist/controllers/highlight_controller.js.map +1 -1
  76. package/dist/controllers/hover_card_controller.d.ts +1 -1
  77. package/dist/controllers/hover_card_controller.js.map +1 -1
  78. package/dist/controllers/intersection_controller.js +67 -19
  79. package/dist/controllers/intersection_controller.js.map +1 -1
  80. package/dist/controllers/lazy_frame_controller.js +54 -11
  81. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  82. package/dist/controllers/listbox_controller.d.ts +9 -2
  83. package/dist/controllers/listbox_controller.js +257 -53
  84. package/dist/controllers/listbox_controller.js.map +1 -1
  85. package/dist/controllers/local_time_controller.js +2 -2
  86. package/dist/controllers/local_time_controller.js.map +1 -1
  87. package/dist/controllers/masonry_controller.d.ts +54 -0
  88. package/dist/controllers/masonry_controller.js +142 -0
  89. package/dist/controllers/masonry_controller.js.map +1 -0
  90. package/dist/controllers/menu_controller.d.ts +29 -11
  91. package/dist/controllers/menu_controller.js +104 -17
  92. package/dist/controllers/menu_controller.js.map +1 -1
  93. package/dist/controllers/menubar_controller.d.ts +142 -0
  94. package/dist/controllers/menubar_controller.js +785 -0
  95. package/dist/controllers/menubar_controller.js.map +1 -0
  96. package/dist/controllers/meter_controller.js.map +1 -1
  97. package/dist/controllers/multi_select_controller.d.ts +141 -0
  98. package/dist/controllers/multi_select_controller.js +755 -0
  99. package/dist/controllers/multi_select_controller.js.map +1 -0
  100. package/dist/controllers/navigation_menu_controller.d.ts +146 -0
  101. package/dist/controllers/navigation_menu_controller.js +511 -0
  102. package/dist/controllers/navigation_menu_controller.js.map +1 -0
  103. package/dist/controllers/nested_form_controller.d.ts +4 -5
  104. package/dist/controllers/nested_form_controller.js.map +1 -1
  105. package/dist/controllers/number_input_controller.d.ts +1 -1
  106. package/dist/controllers/number_input_controller.js +7 -0
  107. package/dist/controllers/number_input_controller.js.map +1 -1
  108. package/dist/controllers/otp_controller.js +18 -1
  109. package/dist/controllers/otp_controller.js.map +1 -1
  110. package/dist/controllers/overflow_indicator_controller.d.ts +15 -6
  111. package/dist/controllers/overflow_indicator_controller.js +246 -27
  112. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  113. package/dist/controllers/overflow_menu_controller.d.ts +78 -19
  114. package/dist/controllers/overflow_menu_controller.js +381 -57
  115. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  116. package/dist/controllers/pagination_controller.d.ts +36 -5
  117. package/dist/controllers/pagination_controller.js +163 -32
  118. package/dist/controllers/pagination_controller.js.map +1 -1
  119. package/dist/controllers/password_reveal_controller.d.ts +49 -0
  120. package/dist/controllers/password_reveal_controller.js +117 -0
  121. package/dist/controllers/password_reveal_controller.js.map +1 -0
  122. package/dist/controllers/persist_controller.js +6 -6
  123. package/dist/controllers/persist_controller.js.map +1 -1
  124. package/dist/controllers/pointer_drag_controller.d.ts +2 -3
  125. package/dist/controllers/pointer_drag_controller.js +9 -1
  126. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  127. package/dist/controllers/popover_controller.d.ts +1 -2
  128. package/dist/controllers/popover_controller.js +2 -2
  129. package/dist/controllers/popover_controller.js.map +1 -1
  130. package/dist/controllers/portal_controller.d.ts +4 -4
  131. package/dist/controllers/portal_controller.js.map +1 -1
  132. package/dist/controllers/progress_controller.js.map +1 -1
  133. package/dist/controllers/radio_group_controller.js +22 -3
  134. package/dist/controllers/radio_group_controller.js.map +1 -1
  135. package/dist/controllers/range_slider_controller.d.ts +97 -0
  136. package/dist/controllers/range_slider_controller.js +192 -0
  137. package/dist/controllers/range_slider_controller.js.map +1 -0
  138. package/dist/controllers/rating_controller.js +16 -2
  139. package/dist/controllers/rating_controller.js.map +1 -1
  140. package/dist/controllers/read_more_controller.d.ts +52 -0
  141. package/dist/controllers/read_more_controller.js +238 -0
  142. package/dist/controllers/read_more_controller.js.map +1 -0
  143. package/dist/controllers/reading_progress_controller.js.map +1 -1
  144. package/dist/controllers/resizable_controller.d.ts +3 -1
  145. package/dist/controllers/resizable_controller.js +65 -1
  146. package/dist/controllers/resizable_controller.js.map +1 -1
  147. package/dist/controllers/roving_controller.js +17 -2
  148. package/dist/controllers/roving_controller.js.map +1 -1
  149. package/dist/controllers/scroll_area_controller.js +101 -14
  150. package/dist/controllers/scroll_area_controller.js.map +1 -1
  151. package/dist/controllers/scroll_restore_controller.d.ts +54 -0
  152. package/dist/controllers/scroll_restore_controller.js +93 -0
  153. package/dist/controllers/scroll_restore_controller.js.map +1 -0
  154. package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
  155. package/dist/controllers/scroll_visibility_controller.js +40 -6
  156. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  157. package/dist/controllers/scrollspy_controller.d.ts +64 -15
  158. package/dist/controllers/scrollspy_controller.js +369 -74
  159. package/dist/controllers/scrollspy_controller.js.map +1 -1
  160. package/dist/controllers/separator_controller.d.ts +69 -0
  161. package/dist/controllers/separator_controller.js +96 -0
  162. package/dist/controllers/separator_controller.js.map +1 -0
  163. package/dist/controllers/sidebar_controller.d.ts +107 -0
  164. package/dist/controllers/sidebar_controller.js +761 -0
  165. package/dist/controllers/sidebar_controller.js.map +1 -0
  166. package/dist/controllers/skeleton_controller.js +1 -1
  167. package/dist/controllers/skeleton_controller.js.map +1 -1
  168. package/dist/controllers/slider_controller.d.ts +11 -1
  169. package/dist/controllers/slider_controller.js +32 -6
  170. package/dist/controllers/slider_controller.js.map +1 -1
  171. package/dist/controllers/sortable_controller.d.ts +3 -1
  172. package/dist/controllers/sortable_controller.js +34 -3
  173. package/dist/controllers/sortable_controller.js.map +1 -1
  174. package/dist/controllers/spinner_controller.js +1 -1
  175. package/dist/controllers/spinner_controller.js.map +1 -1
  176. package/dist/controllers/stepper_controller.d.ts +4 -0
  177. package/dist/controllers/stepper_controller.js +28 -12
  178. package/dist/controllers/stepper_controller.js.map +1 -1
  179. package/dist/controllers/stick_to_bottom_controller.d.ts +5 -6
  180. package/dist/controllers/stick_to_bottom_controller.js +9 -4
  181. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  182. package/dist/controllers/sticky_observer_controller.d.ts +17 -4
  183. package/dist/controllers/sticky_observer_controller.js +109 -20
  184. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  185. package/dist/controllers/submit_once_controller.d.ts +2 -2
  186. package/dist/controllers/submit_once_controller.js.map +1 -1
  187. package/dist/controllers/switch_controller.js +1 -0
  188. package/dist/controllers/switch_controller.js.map +1 -1
  189. package/dist/controllers/tabs_controller.d.ts +13 -4
  190. package/dist/controllers/tabs_controller.js +26 -3
  191. package/dist/controllers/tabs_controller.js.map +1 -1
  192. package/dist/controllers/tags_input_controller.d.ts +91 -0
  193. package/dist/controllers/tags_input_controller.js +295 -0
  194. package/dist/controllers/tags_input_controller.js.map +1 -0
  195. package/dist/controllers/theme_controller.d.ts +1 -1
  196. package/dist/controllers/theme_controller.js +42 -13
  197. package/dist/controllers/theme_controller.js.map +1 -1
  198. package/dist/controllers/time_picker_controller.d.ts +67 -0
  199. package/dist/controllers/time_picker_controller.js +231 -0
  200. package/dist/controllers/time_picker_controller.js.map +1 -0
  201. package/dist/controllers/toast_controller.d.ts +5 -14
  202. package/dist/controllers/toast_controller.js +40 -14
  203. package/dist/controllers/toast_controller.js.map +1 -1
  204. package/dist/controllers/toggle_group_controller.js +23 -2
  205. package/dist/controllers/toggle_group_controller.js.map +1 -1
  206. package/dist/controllers/toolbar_controller.d.ts +53 -13
  207. package/dist/controllers/toolbar_controller.js +230 -31
  208. package/dist/controllers/toolbar_controller.js.map +1 -1
  209. package/dist/controllers/tooltip_controller.d.ts +1 -1
  210. package/dist/controllers/tooltip_controller.js.map +1 -1
  211. package/dist/controllers/transition_controller.d.ts +14 -8
  212. package/dist/controllers/transition_controller.js +153 -38
  213. package/dist/controllers/transition_controller.js.map +1 -1
  214. package/dist/controllers/tree_view_controller.d.ts +135 -0
  215. package/dist/controllers/tree_view_controller.js +691 -0
  216. package/dist/controllers/tree_view_controller.js.map +1 -0
  217. package/dist/index.d.ts +27 -1818
  218. package/dist/index.js +4256 -915
  219. package/dist/index.js.map +1 -1
  220. package/dist/inspector/cli.d.ts +375 -39
  221. package/dist/inspector/cli.js +228 -14
  222. package/dist/inspector/cli.js.map +1 -1
  223. package/dist/inspector/cli_bin.js +419 -48
  224. package/dist/inspector/cli_bin.js.map +1 -1
  225. package/dist/inspector/examples.json +26 -26
  226. package/dist/inspector/manifest.json +1054 -127
  227. 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
- this.#active = true;
29
- this.#observer = new IntersectionObserver(
30
- (entries) => {
31
- if (this.#active) this.#onEntries(entries);
32
- },
33
- { root, rootMargin: options.rootMargin, threshold: options.threshold }
34
- );
35
- for (const target of list) this.#observer.observe(target);
36
- return true;
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
- this.#observer.unobserve(target);
46
- this.#observer.observe(target);
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
- /** Track active/intersecting status of each section element by ID. */
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, used to avoid duplicate event dispatching. */
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
- * Smoothly scrolls to the target element mapped by the link anchor.
100
- * Prevents full window scroll jumps when tracking nested scrollable containers.
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 rootElement = this.#getRootElement();
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 targetRect = targetElement.getBoundingClientRect();
113
- const scrollPosition = rootElement.scrollTop + (targetRect.top - containerRect.top) - this.offsetValue;
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 targetRect = targetElement.getBoundingClientRect();
120
- const scrollPosition = window.scrollY + targetRect.top - this.offsetValue;
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
- #getRootElement() {
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
- return document.querySelector(this.rootSelectorValue);
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 || `-${this.offsetValue}px 0px -80% 0px`;
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.#getRootElement(),
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
- if (!this.#isConnected) return;
394
+ const isDetached = !this.#isConnected;
395
+ if (isDetached) return;
153
396
  for (const entry of entries) {
154
- const id = entry.target.id;
155
- if (!id) continue;
156
- this.#intersectionStates.set(id, {
157
- isIntersecting: entry.isIntersecting,
158
- top: entry.boundingClientRect.top
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.#getRootElement();
165
- const triggerLine = (rootEl ? rootEl.getBoundingClientRect().top : 0) + this.offsetValue;
166
- let bestId = "";
167
- let closestTop = Number.MAX_VALUE;
168
- for (const [id, state] of this.#intersectionStates.entries()) {
169
- if (state.isIntersecting) {
170
- const distance = Math.abs(state.top - triggerLine);
171
- if (distance < closestTop) {
172
- closestTop = distance;
173
- bestId = id;
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
- if (!bestId && this.#intersectionStates.size > 0) {
178
- let absoluteClosestId = "";
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
- #syncActiveStates() {
195
- const activeLink = this.linkTargets.find((l) => this.#getAnchorId(l) === this.#activeSectionId);
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
- const isActive = link === activeLink;
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 (activeLink) {
205
- this.dispatch("change", { detail: { id: this.#activeSectionId, link: activeLink } });
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
- const href = link.getAttribute("href") || link.getAttribute("data-href");
210
- if (!href?.startsWith("#")) return null;
211
- return href.substring(1);
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