stimeo-ui 0.1.0-alpha.1

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 (238) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/dist/controllers/accordion_controller.d.ts +59 -0
  4. package/dist/controllers/accordion_controller.js +76 -0
  5. package/dist/controllers/accordion_controller.js.map +1 -0
  6. package/dist/controllers/announcer_controller.d.ts +78 -0
  7. package/dist/controllers/announcer_controller.js +184 -0
  8. package/dist/controllers/announcer_controller.js.map +1 -0
  9. package/dist/controllers/aspect_ratio_controller.d.ts +39 -0
  10. package/dist/controllers/aspect_ratio_controller.js +36 -0
  11. package/dist/controllers/aspect_ratio_controller.js.map +1 -0
  12. package/dist/controllers/auto_submit_controller.d.ts +74 -0
  13. package/dist/controllers/auto_submit_controller.js +147 -0
  14. package/dist/controllers/auto_submit_controller.js.map +1 -0
  15. package/dist/controllers/avatar_controller.d.ts +50 -0
  16. package/dist/controllers/avatar_controller.js +66 -0
  17. package/dist/controllers/avatar_controller.js.map +1 -0
  18. package/dist/controllers/breadcrumb_controller.d.ts +60 -0
  19. package/dist/controllers/breadcrumb_controller.js +123 -0
  20. package/dist/controllers/breadcrumb_controller.js.map +1 -0
  21. package/dist/controllers/bulk_select_controller.d.ts +67 -0
  22. package/dist/controllers/bulk_select_controller.js +104 -0
  23. package/dist/controllers/bulk_select_controller.js.map +1 -0
  24. package/dist/controllers/calendar_controller.d.ts +100 -0
  25. package/dist/controllers/calendar_controller.js +394 -0
  26. package/dist/controllers/calendar_controller.js.map +1 -0
  27. package/dist/controllers/character_counter_controller.d.ts +63 -0
  28. package/dist/controllers/character_counter_controller.js +179 -0
  29. package/dist/controllers/character_counter_controller.js.map +1 -0
  30. package/dist/controllers/checkbox_controller.d.ts +52 -0
  31. package/dist/controllers/checkbox_controller.js +73 -0
  32. package/dist/controllers/checkbox_controller.js.map +1 -0
  33. package/dist/controllers/combobox_controller.d.ts +76 -0
  34. package/dist/controllers/combobox_controller.js +186 -0
  35. package/dist/controllers/combobox_controller.js.map +1 -0
  36. package/dist/controllers/command_palette_controller.d.ts +103 -0
  37. package/dist/controllers/command_palette_controller.js +381 -0
  38. package/dist/controllers/command_palette_controller.js.map +1 -0
  39. package/dist/controllers/conditional_fields_controller.d.ts +56 -0
  40. package/dist/controllers/conditional_fields_controller.js +112 -0
  41. package/dist/controllers/conditional_fields_controller.js.map +1 -0
  42. package/dist/controllers/confirm_controller.d.ts +83 -0
  43. package/dist/controllers/confirm_controller.js +276 -0
  44. package/dist/controllers/confirm_controller.js.map +1 -0
  45. package/dist/controllers/context_menu_controller.d.ts +81 -0
  46. package/dist/controllers/context_menu_controller.js +112 -0
  47. package/dist/controllers/context_menu_controller.js.map +1 -0
  48. package/dist/controllers/countdown_controller.d.ts +93 -0
  49. package/dist/controllers/countdown_controller.js +202 -0
  50. package/dist/controllers/countdown_controller.js.map +1 -0
  51. package/dist/controllers/dialog_controller.d.ts +57 -0
  52. package/dist/controllers/dialog_controller.js +207 -0
  53. package/dist/controllers/dialog_controller.js.map +1 -0
  54. package/dist/controllers/direct_upload_controller.d.ts +77 -0
  55. package/dist/controllers/direct_upload_controller.js +212 -0
  56. package/dist/controllers/direct_upload_controller.js.map +1 -0
  57. package/dist/controllers/dirty_form_controller.d.ts +52 -0
  58. package/dist/controllers/dirty_form_controller.js +128 -0
  59. package/dist/controllers/dirty_form_controller.js.map +1 -0
  60. package/dist/controllers/dropdown_controller.d.ts +49 -0
  61. package/dist/controllers/dropdown_controller.js +66 -0
  62. package/dist/controllers/dropdown_controller.js.map +1 -0
  63. package/dist/controllers/empty_state_controller.d.ts +60 -0
  64. package/dist/controllers/empty_state_controller.js +67 -0
  65. package/dist/controllers/empty_state_controller.js.map +1 -0
  66. package/dist/controllers/flash_controller.d.ts +63 -0
  67. package/dist/controllers/flash_controller.js +221 -0
  68. package/dist/controllers/flash_controller.js.map +1 -0
  69. package/dist/controllers/focus_controller.d.ts +69 -0
  70. package/dist/controllers/focus_controller.js +216 -0
  71. package/dist/controllers/focus_controller.js.map +1 -0
  72. package/dist/controllers/form_field_controller.d.ts +79 -0
  73. package/dist/controllers/form_field_controller.js +154 -0
  74. package/dist/controllers/form_field_controller.js.map +1 -0
  75. package/dist/controllers/form_validation_controller.d.ts +114 -0
  76. package/dist/controllers/form_validation_controller.js +202 -0
  77. package/dist/controllers/form_validation_controller.js.map +1 -0
  78. package/dist/controllers/frame_loading_controller.d.ts +58 -0
  79. package/dist/controllers/frame_loading_controller.js +177 -0
  80. package/dist/controllers/frame_loading_controller.js.map +1 -0
  81. package/dist/controllers/highlight_controller.d.ts +50 -0
  82. package/dist/controllers/highlight_controller.js +107 -0
  83. package/dist/controllers/highlight_controller.js.map +1 -0
  84. package/dist/controllers/hover_card_controller.d.ts +79 -0
  85. package/dist/controllers/hover_card_controller.js +165 -0
  86. package/dist/controllers/hover_card_controller.js.map +1 -0
  87. package/dist/controllers/idle_controller.d.ts +51 -0
  88. package/dist/controllers/idle_controller.js +141 -0
  89. package/dist/controllers/idle_controller.js.map +1 -0
  90. package/dist/controllers/input_mask_controller.d.ts +74 -0
  91. package/dist/controllers/input_mask_controller.js +166 -0
  92. package/dist/controllers/input_mask_controller.js.map +1 -0
  93. package/dist/controllers/lazy_frame_controller.d.ts +53 -0
  94. package/dist/controllers/lazy_frame_controller.js +68 -0
  95. package/dist/controllers/lazy_frame_controller.js.map +1 -0
  96. package/dist/controllers/listbox_controller.d.ts +88 -0
  97. package/dist/controllers/listbox_controller.js +256 -0
  98. package/dist/controllers/listbox_controller.js.map +1 -0
  99. package/dist/controllers/local_time_controller.d.ts +60 -0
  100. package/dist/controllers/local_time_controller.js +81 -0
  101. package/dist/controllers/local_time_controller.js.map +1 -0
  102. package/dist/controllers/menu_controller.d.ts +81 -0
  103. package/dist/controllers/menu_controller.js +134 -0
  104. package/dist/controllers/menu_controller.js.map +1 -0
  105. package/dist/controllers/meter_controller.d.ts +93 -0
  106. package/dist/controllers/meter_controller.js +96 -0
  107. package/dist/controllers/meter_controller.js.map +1 -0
  108. package/dist/controllers/nested_form_controller.d.ts +85 -0
  109. package/dist/controllers/nested_form_controller.js +131 -0
  110. package/dist/controllers/nested_form_controller.js.map +1 -0
  111. package/dist/controllers/network_status_controller.d.ts +49 -0
  112. package/dist/controllers/network_status_controller.js +126 -0
  113. package/dist/controllers/network_status_controller.js.map +1 -0
  114. package/dist/controllers/number_input_controller.d.ts +93 -0
  115. package/dist/controllers/number_input_controller.js +306 -0
  116. package/dist/controllers/number_input_controller.js.map +1 -0
  117. package/dist/controllers/otp_controller.d.ts +68 -0
  118. package/dist/controllers/otp_controller.js +201 -0
  119. package/dist/controllers/otp_controller.js.map +1 -0
  120. package/dist/controllers/overflow_indicator_controller.d.ts +62 -0
  121. package/dist/controllers/overflow_indicator_controller.js +169 -0
  122. package/dist/controllers/overflow_indicator_controller.js.map +1 -0
  123. package/dist/controllers/overflow_menu_controller.d.ts +64 -0
  124. package/dist/controllers/overflow_menu_controller.js +274 -0
  125. package/dist/controllers/overflow_menu_controller.js.map +1 -0
  126. package/dist/controllers/pagination_controller.d.ts +69 -0
  127. package/dist/controllers/pagination_controller.js +89 -0
  128. package/dist/controllers/pagination_controller.js.map +1 -0
  129. package/dist/controllers/password_strength_controller.d.ts +67 -0
  130. package/dist/controllers/password_strength_controller.js +175 -0
  131. package/dist/controllers/password_strength_controller.js.map +1 -0
  132. package/dist/controllers/persist_controller.d.ts +63 -0
  133. package/dist/controllers/persist_controller.js +259 -0
  134. package/dist/controllers/persist_controller.js.map +1 -0
  135. package/dist/controllers/popover_controller.d.ts +63 -0
  136. package/dist/controllers/popover_controller.js +94 -0
  137. package/dist/controllers/popover_controller.js.map +1 -0
  138. package/dist/controllers/portal_controller.d.ts +58 -0
  139. package/dist/controllers/portal_controller.js +63 -0
  140. package/dist/controllers/portal_controller.js.map +1 -0
  141. package/dist/controllers/preview_guard_controller.d.ts +47 -0
  142. package/dist/controllers/preview_guard_controller.js +69 -0
  143. package/dist/controllers/preview_guard_controller.js.map +1 -0
  144. package/dist/controllers/progress_controller.d.ts +85 -0
  145. package/dist/controllers/progress_controller.js +93 -0
  146. package/dist/controllers/progress_controller.js.map +1 -0
  147. package/dist/controllers/radio_group_controller.d.ts +49 -0
  148. package/dist/controllers/radio_group_controller.js +128 -0
  149. package/dist/controllers/radio_group_controller.js.map +1 -0
  150. package/dist/controllers/rating_controller.d.ts +82 -0
  151. package/dist/controllers/rating_controller.js +179 -0
  152. package/dist/controllers/rating_controller.js.map +1 -0
  153. package/dist/controllers/relative_time_controller.d.ts +48 -0
  154. package/dist/controllers/relative_time_controller.js +129 -0
  155. package/dist/controllers/relative_time_controller.js.map +1 -0
  156. package/dist/controllers/reset_before_cache_controller.d.ts +54 -0
  157. package/dist/controllers/reset_before_cache_controller.js +62 -0
  158. package/dist/controllers/reset_before_cache_controller.js.map +1 -0
  159. package/dist/controllers/resizable_controller.d.ts +81 -0
  160. package/dist/controllers/resizable_controller.js +163 -0
  161. package/dist/controllers/resizable_controller.js.map +1 -0
  162. package/dist/controllers/roving_controller.d.ts +63 -0
  163. package/dist/controllers/roving_controller.js +116 -0
  164. package/dist/controllers/roving_controller.js.map +1 -0
  165. package/dist/controllers/scroll_area_controller.d.ts +45 -0
  166. package/dist/controllers/scroll_area_controller.js +183 -0
  167. package/dist/controllers/scroll_area_controller.js.map +1 -0
  168. package/dist/controllers/scroll_visibility_controller.d.ts +72 -0
  169. package/dist/controllers/scroll_visibility_controller.js +103 -0
  170. package/dist/controllers/scroll_visibility_controller.js.map +1 -0
  171. package/dist/controllers/scrollspy_controller.d.ts +65 -0
  172. package/dist/controllers/scrollspy_controller.js +171 -0
  173. package/dist/controllers/scrollspy_controller.js.map +1 -0
  174. package/dist/controllers/skeleton_controller.d.ts +50 -0
  175. package/dist/controllers/skeleton_controller.js +125 -0
  176. package/dist/controllers/skeleton_controller.js.map +1 -0
  177. package/dist/controllers/slider_controller.d.ts +76 -0
  178. package/dist/controllers/slider_controller.js +109 -0
  179. package/dist/controllers/slider_controller.js.map +1 -0
  180. package/dist/controllers/spinner_controller.d.ts +62 -0
  181. package/dist/controllers/spinner_controller.js +164 -0
  182. package/dist/controllers/spinner_controller.js.map +1 -0
  183. package/dist/controllers/step_indicator_controller.d.ts +55 -0
  184. package/dist/controllers/step_indicator_controller.js +55 -0
  185. package/dist/controllers/step_indicator_controller.js.map +1 -0
  186. package/dist/controllers/stepper_controller.d.ts +66 -0
  187. package/dist/controllers/stepper_controller.js +78 -0
  188. package/dist/controllers/stepper_controller.js.map +1 -0
  189. package/dist/controllers/stick_to_bottom_controller.d.ts +61 -0
  190. package/dist/controllers/stick_to_bottom_controller.js +100 -0
  191. package/dist/controllers/stick_to_bottom_controller.js.map +1 -0
  192. package/dist/controllers/sticky_observer_controller.d.ts +54 -0
  193. package/dist/controllers/sticky_observer_controller.js +53 -0
  194. package/dist/controllers/sticky_observer_controller.js.map +1 -0
  195. package/dist/controllers/submit_once_controller.d.ts +66 -0
  196. package/dist/controllers/submit_once_controller.js +206 -0
  197. package/dist/controllers/submit_once_controller.js.map +1 -0
  198. package/dist/controllers/switch_controller.d.ts +43 -0
  199. package/dist/controllers/switch_controller.js +50 -0
  200. package/dist/controllers/switch_controller.js.map +1 -0
  201. package/dist/controllers/tabs_controller.d.ts +48 -0
  202. package/dist/controllers/tabs_controller.js +63 -0
  203. package/dist/controllers/tabs_controller.js.map +1 -0
  204. package/dist/controllers/textarea_autosize_controller.d.ts +47 -0
  205. package/dist/controllers/textarea_autosize_controller.js +72 -0
  206. package/dist/controllers/textarea_autosize_controller.js.map +1 -0
  207. package/dist/controllers/theme_controller.d.ts +70 -0
  208. package/dist/controllers/theme_controller.js +154 -0
  209. package/dist/controllers/theme_controller.js.map +1 -0
  210. package/dist/controllers/toast_controller.d.ts +123 -0
  211. package/dist/controllers/toast_controller.js +310 -0
  212. package/dist/controllers/toast_controller.js.map +1 -0
  213. package/dist/controllers/toggle_group_controller.d.ts +56 -0
  214. package/dist/controllers/toggle_group_controller.js +130 -0
  215. package/dist/controllers/toggle_group_controller.js.map +1 -0
  216. package/dist/controllers/toolbar_controller.d.ts +59 -0
  217. package/dist/controllers/toolbar_controller.js +113 -0
  218. package/dist/controllers/toolbar_controller.js.map +1 -0
  219. package/dist/controllers/tooltip_controller.d.ts +74 -0
  220. package/dist/controllers/tooltip_controller.js +165 -0
  221. package/dist/controllers/tooltip_controller.js.map +1 -0
  222. package/dist/controllers/transition_controller.d.ts +86 -0
  223. package/dist/controllers/transition_controller.js +203 -0
  224. package/dist/controllers/transition_controller.js.map +1 -0
  225. package/dist/index.d.ts +1931 -0
  226. package/dist/index.js +12241 -0
  227. package/dist/index.js.map +1 -0
  228. package/dist/inspector/cli.d.ts +149 -0
  229. package/dist/inspector/cli.js +663 -0
  230. package/dist/inspector/cli.js.map +1 -0
  231. package/dist/inspector/cli_bin.d.ts +2 -0
  232. package/dist/inspector/cli_bin.js +665 -0
  233. package/dist/inspector/cli_bin.js.map +1 -0
  234. package/dist/inspector/manifest.json +2235 -0
  235. package/dist/positioning/index.d.ts +226 -0
  236. package/dist/positioning/index.js +145 -0
  237. package/dist/positioning/index.js.map +1 -0
  238. package/package.json +93 -0
@@ -0,0 +1,171 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/scrollspy_controller.ts
4
+ var ScrollspyController = class extends Controller {
5
+ static targets = ["link"];
6
+ static values = {
7
+ offset: { type: Number, default: 0 },
8
+ rootMargin: { type: String, default: "" },
9
+ rootSelector: { type: String, default: "" }
10
+ };
11
+ static actions = ["scrollTo"];
12
+ static events = ["change"];
13
+ #observer = null;
14
+ #isConnected = false;
15
+ /** Track active/intersecting status of each section element by ID. */
16
+ #intersectionStates = /* @__PURE__ */ new Map();
17
+ /** Current active section ID, used to avoid duplicate event dispatching. */
18
+ #activeSectionId = "";
19
+ connect() {
20
+ this.#isConnected = true;
21
+ this.#initializeObserver();
22
+ }
23
+ disconnect() {
24
+ this.#isConnected = false;
25
+ if (this.#observer) {
26
+ this.#observer.disconnect();
27
+ this.#observer = null;
28
+ }
29
+ this.#intersectionStates.clear();
30
+ this.#activeSectionId = "";
31
+ }
32
+ /**
33
+ * Re-initializes the observer if the offset or rootMargin values change dynamically.
34
+ */
35
+ offsetValueChanged() {
36
+ if (!this.#isConnected) return;
37
+ this.#initializeObserver();
38
+ }
39
+ rootMarginValueChanged() {
40
+ if (!this.#isConnected) return;
41
+ this.#initializeObserver();
42
+ }
43
+ rootSelectorValueChanged() {
44
+ if (!this.#isConnected) return;
45
+ this.#initializeObserver();
46
+ }
47
+ /**
48
+ * Smoothly scrolls to the target element mapped by the link anchor.
49
+ * Prevents full window scroll jumps when tracking nested scrollable containers.
50
+ */
51
+ scrollTo(event) {
52
+ const link = event.currentTarget;
53
+ const id = this.#getAnchorId(link);
54
+ if (!id) return;
55
+ event.preventDefault();
56
+ const targetElement = document.getElementById(id);
57
+ if (!targetElement) return;
58
+ const rootElement = this.#getRootElement();
59
+ if (rootElement) {
60
+ const containerRect = rootElement.getBoundingClientRect();
61
+ const targetRect = targetElement.getBoundingClientRect();
62
+ const scrollPosition = rootElement.scrollTop + (targetRect.top - containerRect.top) - this.offsetValue;
63
+ rootElement.scrollTo({
64
+ top: scrollPosition,
65
+ behavior: "smooth"
66
+ });
67
+ } else {
68
+ const targetRect = targetElement.getBoundingClientRect();
69
+ const scrollPosition = window.scrollY + targetRect.top - this.offsetValue;
70
+ window.scrollTo({
71
+ top: scrollPosition,
72
+ behavior: "smooth"
73
+ });
74
+ }
75
+ }
76
+ #getRootElement() {
77
+ if (!this.rootSelectorValue) return null;
78
+ return document.querySelector(this.rootSelectorValue);
79
+ }
80
+ #initializeObserver() {
81
+ if (this.#observer) {
82
+ this.#observer.disconnect();
83
+ this.#observer = null;
84
+ }
85
+ this.#intersectionStates.clear();
86
+ this.#activeSectionId = "";
87
+ if (this.linkTargets.length === 0) return;
88
+ const margin = this.rootMarginValue || `-${this.offsetValue}px 0px -80% 0px`;
89
+ const rootEl = this.#getRootElement();
90
+ this.#observer = new IntersectionObserver(this.#onIntersection, {
91
+ root: rootEl,
92
+ rootMargin: margin,
93
+ threshold: [0, 0.2, 0.4, 0.6, 0.8, 1]
94
+ // Multiple thresholds handle large sections safely
95
+ });
96
+ for (const link of this.linkTargets) {
97
+ const id = this.#getAnchorId(link);
98
+ if (!id) continue;
99
+ const section = document.getElementById(id);
100
+ if (section) {
101
+ this.#observer.observe(section);
102
+ }
103
+ }
104
+ }
105
+ #onIntersection = (entries) => {
106
+ if (!this.#isConnected) return;
107
+ for (const entry of entries) {
108
+ const id = entry.target.id;
109
+ if (!id) continue;
110
+ this.#intersectionStates.set(id, {
111
+ isIntersecting: entry.isIntersecting,
112
+ top: entry.boundingClientRect.top
113
+ });
114
+ }
115
+ this.#evaluateActiveSection();
116
+ };
117
+ #evaluateActiveSection() {
118
+ const rootEl = this.#getRootElement();
119
+ const triggerLine = (rootEl ? rootEl.getBoundingClientRect().top : 0) + this.offsetValue;
120
+ let bestId = "";
121
+ let closestTop = Number.MAX_VALUE;
122
+ for (const [id, state] of this.#intersectionStates.entries()) {
123
+ if (state.isIntersecting) {
124
+ const distance = Math.abs(state.top - triggerLine);
125
+ if (distance < closestTop) {
126
+ closestTop = distance;
127
+ bestId = id;
128
+ }
129
+ }
130
+ }
131
+ if (!bestId && this.#intersectionStates.size > 0) {
132
+ let absoluteClosestId = "";
133
+ let absoluteClosestTop = Number.MAX_VALUE;
134
+ for (const [id, state] of this.#intersectionStates.entries()) {
135
+ const distance = Math.abs(state.top - triggerLine);
136
+ if (distance < absoluteClosestTop) {
137
+ absoluteClosestTop = distance;
138
+ absoluteClosestId = id;
139
+ }
140
+ }
141
+ bestId = absoluteClosestId;
142
+ }
143
+ if (bestId && bestId !== this.#activeSectionId) {
144
+ this.#activeSectionId = bestId;
145
+ this.#syncActiveStates();
146
+ }
147
+ }
148
+ #syncActiveStates() {
149
+ const activeLink = this.linkTargets.find((l) => this.#getAnchorId(l) === this.#activeSectionId);
150
+ for (const link of this.linkTargets) {
151
+ const isActive = link === activeLink;
152
+ if (isActive) {
153
+ link.setAttribute("aria-current", "location");
154
+ } else {
155
+ link.removeAttribute("aria-current");
156
+ }
157
+ }
158
+ if (activeLink) {
159
+ this.dispatch("change", { detail: { id: this.#activeSectionId, link: activeLink } });
160
+ }
161
+ }
162
+ #getAnchorId(link) {
163
+ const href = link.getAttribute("href") || link.getAttribute("data-href");
164
+ if (!href?.startsWith("#")) return null;
165
+ return href.substring(1);
166
+ }
167
+ };
168
+
169
+ export { ScrollspyController };
170
+ //# sourceMappingURL=scrollspy_controller.js.map
171
+ //# sourceMappingURL=scrollspy_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/scrollspy_controller.ts"],"names":[],"mappings":";;;AA0BO,IAAM,mBAAA,GAAN,cAAkC,UAAA,CAAwB;AAAA,EAC/D,OAAgB,OAAA,GAAU,CAAC,MAAM,CAAA;AAAA,EACjC,OAAgB,MAAA,GAAS;AAAA,IACvB,MAAA,EAAQ,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IACnC,UAAA,EAAY,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IACxC,YAAA,EAAc,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA;AAAG,GAC5C;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,UAAU,CAAA;AAAA,EAC5B,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA,EAQzB,SAAA,GAAyC,IAAA;AAAA,EACzC,YAAA,GAAe,KAAA;AAAA;AAAA,EAGf,mBAAA,uBAA0B,GAAA,EAAsD;AAAA;AAAA,EAGhF,gBAAA,GAAmB,EAAA;AAAA,EAEV,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,YAAA,GAAe,IAAA;AACpB,IAAA,IAAA,CAAK,mBAAA,EAAoB;AAAA,EAC3B;AAAA,EAES,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,YAAA,GAAe,KAAA;AACpB,IAAA,IAAI,KAAK,SAAA,EAAW;AAClB,MAAA,IAAA,CAAK,UAAU,UAAA,EAAW;AAC1B,MAAA,IAAA,CAAK,SAAA,GAAY,IAAA;AAAA,IACnB;AACA,IAAA,IAAA,CAAK,oBAAoB,KAAA,EAAM;AAC/B,IAAA,IAAA,CAAK,gBAAA,GAAmB,EAAA;AAAA,EAC1B;AAAA;AAAA;AAAA;AAAA,EAKA,kBAAA,GAA2B;AACzB,IAAA,IAAI,CAAC,KAAK,YAAA,EAAc;AACxB,IAAA,IAAA,CAAK,mBAAA,EAAoB;AAAA,EAC3B;AAAA,EAEA,sBAAA,GAA+B;AAC7B,IAAA,IAAI,CAAC,KAAK,YAAA,EAAc;AACxB,IAAA,IAAA,CAAK,mBAAA,EAAoB;AAAA,EAC3B;AAAA,EAEA,wBAAA,GAAiC;AAC/B,IAAA,IAAI,CAAC,KAAK,YAAA,EAAc;AACxB,IAAA,IAAA,CAAK,mBAAA,EAAoB;AAAA,EAC3B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,SAAS,KAAA,EAAoB;AAC3B,IAAA,MAAM,OAAO,KAAA,CAAM,aAAA;AACnB,IAAA,MAAM,EAAA,GAAK,IAAA,CAAK,YAAA,CAAa,IAAI,CAAA;AACjC,IAAA,IAAI,CAAC,EAAA,EAAI;AAET,IAAA,KAAA,CAAM,cAAA,EAAe;AAErB,IAAA,MAAM,aAAA,GAAgB,QAAA,CAAS,cAAA,CAAe,EAAE,CAAA;AAChD,IAAA,IAAI,CAAC,aAAA,EAAe;AAEpB,IAAA,MAAM,WAAA,GAAc,KAAK,eAAA,EAAgB;AACzC,IAAA,IAAI,WAAA,EAAa;AACf,MAAA,MAAM,aAAA,GAAgB,YAAY,qBAAA,EAAsB;AACxD,MAAA,MAAM,UAAA,GAAa,cAAc,qBAAA,EAAsB;AACvD,MAAA,MAAM,iBACJ,WAAA,CAAY,SAAA,IAAa,WAAW,GAAA,GAAM,aAAA,CAAc,OAAO,IAAA,CAAK,WAAA;AAEtE,MAAA,WAAA,CAAY,QAAA,CAAS;AAAA,QACnB,GAAA,EAAK,cAAA;AAAA,QACL,QAAA,EAAU;AAAA,OACX,CAAA;AAAA,IACH,CAAA,MAAO;AACL,MAAA,MAAM,UAAA,GAAa,cAAc,qBAAA,EAAsB;AACvD,MAAA,MAAM,cAAA,GAAiB,MAAA,CAAO,OAAA,GAAU,UAAA,CAAW,MAAM,IAAA,CAAK,WAAA;AAE9D,MAAA,MAAA,CAAO,QAAA,CAAS;AAAA,QACd,GAAA,EAAK,cAAA;AAAA,QACL,QAAA,EAAU;AAAA,OACX,CAAA;AAAA,IACH;AAAA,EACF;AAAA,EAEA,eAAA,GAAsC;AACpC,IAAA,IAAI,CAAC,IAAA,CAAK,iBAAA,EAAmB,OAAO,IAAA;AACpC,IAAA,OAAO,QAAA,CAAS,aAAA,CAAc,IAAA,CAAK,iBAAiB,CAAA;AAAA,EACtD;AAAA,EAEA,mBAAA,GAA4B;AAC1B,IAAA,IAAI,KAAK,SAAA,EAAW;AAClB,MAAA,IAAA,CAAK,UAAU,UAAA,EAAW;AAC1B,MAAA,IAAA,CAAK,SAAA,GAAY,IAAA;AAAA,IACnB;AACA,IAAA,IAAA,CAAK,oBAAoB,KAAA,EAAM;AAC/B,IAAA,IAAA,CAAK,gBAAA,GAAmB,EAAA;AAExB,IAAA,IAAI,IAAA,CAAK,WAAA,CAAY,MAAA,KAAW,CAAA,EAAG;AAGnC,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,eAAA,IAAmB,CAAA,CAAA,EAAI,KAAK,WAAW,CAAA,eAAA,CAAA;AAC3D,IAAA,MAAM,MAAA,GAAS,KAAK,eAAA,EAAgB;AAEpC,IAAA,IAAA,CAAK,SAAA,GAAY,IAAI,oBAAA,CAAqB,IAAA,CAAK,eAAA,EAAiB;AAAA,MAC9D,IAAA,EAAM,MAAA;AAAA,MACN,UAAA,EAAY,MAAA;AAAA,MACZ,WAAW,CAAC,CAAA,EAAG,KAAK,GAAA,EAAK,GAAA,EAAK,KAAK,CAAC;AAAA;AAAA,KACrC,CAAA;AAGD,IAAA,KAAA,MAAW,IAAA,IAAQ,KAAK,WAAA,EAAa;AACnC,MAAA,MAAM,EAAA,GAAK,IAAA,CAAK,YAAA,CAAa,IAAI,CAAA;AACjC,MAAA,IAAI,CAAC,EAAA,EAAI;AAET,MAAA,MAAM,OAAA,GAAU,QAAA,CAAS,cAAA,CAAe,EAAE,CAAA;AAC1C,MAAA,IAAI,OAAA,EAAS;AACX,QAAA,IAAA,CAAK,SAAA,CAAU,QAAQ,OAAO,CAAA;AAAA,MAChC;AAAA,IACF;AAAA,EACF;AAAA,EAES,eAAA,GAAkB,CAAC,OAAA,KAA+C;AAIzE,IAAA,IAAI,CAAC,KAAK,YAAA,EAAc;AAExB,IAAA,KAAA,MAAW,SAAS,OAAA,EAAS;AAC3B,MAAA,MAAM,EAAA,GAAK,MAAM,MAAA,CAAO,EAAA;AACxB,MAAA,IAAI,CAAC,EAAA,EAAI;AAET,MAAA,IAAA,CAAK,mBAAA,CAAoB,IAAI,EAAA,EAAI;AAAA,QAC/B,gBAAgB,KAAA,CAAM,cAAA;AAAA,QACtB,GAAA,EAAK,MAAM,kBAAA,CAAmB;AAAA,OAC/B,CAAA;AAAA,IACH;AAEA,IAAA,IAAA,CAAK,sBAAA,EAAuB;AAAA,EAC9B,CAAA;AAAA,EAEA,sBAAA,GAA+B;AAM7B,IAAA,MAAM,MAAA,GAAS,KAAK,eAAA,EAAgB;AACpC,IAAA,MAAM,eAAe,MAAA,GAAS,MAAA,CAAO,uBAAsB,CAAE,GAAA,GAAM,KAAK,IAAA,CAAK,WAAA;AAE7E,IAAA,IAAI,MAAA,GAAS,EAAA;AACb,IAAA,IAAI,aAAa,MAAA,CAAO,SAAA;AAGxB,IAAA,KAAA,MAAW,CAAC,EAAA,EAAI,KAAK,KAAK,IAAA,CAAK,mBAAA,CAAoB,SAAQ,EAAG;AAC5D,MAAA,IAAI,MAAM,cAAA,EAAgB;AAExB,QAAA,MAAM,QAAA,GAAW,IAAA,CAAK,GAAA,CAAI,KAAA,CAAM,MAAM,WAAW,CAAA;AACjD,QAAA,IAAI,WAAW,UAAA,EAAY;AACzB,UAAA,UAAA,GAAa,QAAA;AACb,UAAA,MAAA,GAAS,EAAA;AAAA,QACX;AAAA,MACF;AAAA,IACF;AAKA,IAAA,IAAI,CAAC,MAAA,IAAU,IAAA,CAAK,mBAAA,CAAoB,OAAO,CAAA,EAAG;AAChD,MAAA,IAAI,iBAAA,GAAoB,EAAA;AACxB,MAAA,IAAI,qBAAqB,MAAA,CAAO,SAAA;AAEhC,MAAA,KAAA,MAAW,CAAC,EAAA,EAAI,KAAK,KAAK,IAAA,CAAK,mBAAA,CAAoB,SAAQ,EAAG;AAC5D,QAAA,MAAM,QAAA,GAAW,IAAA,CAAK,GAAA,CAAI,KAAA,CAAM,MAAM,WAAW,CAAA;AACjD,QAAA,IAAI,WAAW,kBAAA,EAAoB;AACjC,UAAA,kBAAA,GAAqB,QAAA;AACrB,UAAA,iBAAA,GAAoB,EAAA;AAAA,QACtB;AAAA,MACF;AACA,MAAA,MAAA,GAAS,iBAAA;AAAA,IACX;AAEA,IAAA,IAAI,MAAA,IAAU,MAAA,KAAW,IAAA,CAAK,gBAAA,EAAkB;AAC9C,MAAA,IAAA,CAAK,gBAAA,GAAmB,MAAA;AACxB,MAAA,IAAA,CAAK,iBAAA,EAAkB;AAAA,IACzB;AAAA,EACF;AAAA,EAEA,iBAAA,GAA0B;AACxB,IAAA,MAAM,UAAA,GAAa,IAAA,CAAK,WAAA,CAAY,IAAA,CAAK,CAAC,CAAA,KAAM,IAAA,CAAK,YAAA,CAAa,CAAC,CAAA,KAAM,IAAA,CAAK,gBAAgB,CAAA;AAE9F,IAAA,KAAA,MAAW,IAAA,IAAQ,KAAK,WAAA,EAAa;AACnC,MAAA,MAAM,WAAW,IAAA,KAAS,UAAA;AAC1B,MAAA,IAAI,QAAA,EAAU;AACZ,QAAA,IAAA,CAAK,YAAA,CAAa,gBAAgB,UAAU,CAAA;AAAA,MAC9C,CAAA,MAAO;AACL,QAAA,IAAA,CAAK,gBAAgB,cAAc,CAAA;AAAA,MACrC;AAAA,IACF;AAEA,IAAA,IAAI,UAAA,EAAY;AACd,MAAA,IAAA,CAAK,QAAA,CAAS,QAAA,EAAU,EAAE,MAAA,EAAQ,EAAE,EAAA,EAAI,IAAA,CAAK,gBAAA,EAAkB,IAAA,EAAM,UAAA,EAAW,EAAG,CAAA;AAAA,IACrF;AAAA,EACF;AAAA,EAEA,aAAa,IAAA,EAAkC;AAC7C,IAAA,MAAM,OAAO,IAAA,CAAK,YAAA,CAAa,MAAM,CAAA,IAAK,IAAA,CAAK,aAAa,WAAW,CAAA;AACvE,IAAA,IAAI,CAAC,IAAA,EAAM,UAAA,CAAW,GAAG,GAAG,OAAO,IAAA;AACnC,IAAA,OAAO,IAAA,CAAK,UAAU,CAAC,CAAA;AAAA,EACzB;AACF","file":"scrollspy_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible Scrollspy / Navigation catalog synchronizer.\n *\n * Markup contract (identifier: `stimeo--scrollspy`):\n * <nav data-controller=\"stimeo--scrollspy\"\n * data-stimeo--scrollspy-offset-value=\"80\"\n * aria-label=\"Table of contents\">\n * <a href=\"#intro\" data-stimeo--scrollspy-target=\"link\">Intro</a>\n * <a href=\"#usage\" data-stimeo--scrollspy-target=\"link\">Usage</a>\n * </nav>\n * <section id=\"intro\">…</section>\n * <section id=\"usage\">…</section>\n *\n * Implements clean active location synchronization driven by `aria-current`:\n * - Leverages native `IntersectionObserver` to track viewport visibility.\n * - Robust evaluation algorithm: among all intersecting sections, the one closest\n * to the top offset trigger line (`boundingClientRect.top`) is prioritized as active.\n * - Toggles `aria-current=\"location\"` dynamically on active/inactive links.\n * - Gracefully handles teardown `disconnect()` by severing observer references to prevent leaks.\n *\n * @remarks\n * Behavior only. The controller manages state on catalog links via `aria-current`,\n * respects custom offsets, and dispatches `stimeo--scrollspy:change` events.\n */\nexport class ScrollspyController extends Controller<HTMLElement> {\n static override targets = [\"link\"];\n static override values = {\n offset: { type: Number, default: 0 },\n rootMargin: { type: String, default: \"\" },\n rootSelector: { type: String, default: \"\" },\n };\n static actions = [\"scrollTo\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly linkTargets: HTMLElement[];\n\n declare offsetValue: number;\n declare rootMarginValue: string;\n declare rootSelectorValue: string;\n\n #observer: IntersectionObserver | null = null;\n #isConnected = false;\n\n /** Track active/intersecting status of each section element by ID. */\n #intersectionStates = new Map<string, { isIntersecting: boolean; top: number }>();\n\n /** Current active section ID, used to avoid duplicate event dispatching. */\n #activeSectionId = \"\";\n\n override connect(): void {\n this.#isConnected = true;\n this.#initializeObserver();\n }\n\n override disconnect(): void {\n this.#isConnected = false;\n if (this.#observer) {\n this.#observer.disconnect();\n this.#observer = null;\n }\n this.#intersectionStates.clear();\n this.#activeSectionId = \"\";\n }\n\n /**\n * Re-initializes the observer if the offset or rootMargin values change dynamically.\n */\n offsetValueChanged(): void {\n if (!this.#isConnected) return;\n this.#initializeObserver();\n }\n\n rootMarginValueChanged(): void {\n if (!this.#isConnected) return;\n this.#initializeObserver();\n }\n\n rootSelectorValueChanged(): void {\n if (!this.#isConnected) return;\n this.#initializeObserver();\n }\n\n /**\n * Smoothly scrolls to the target element mapped by the link anchor.\n * Prevents full window scroll jumps when tracking nested scrollable containers.\n */\n scrollTo(event: Event): void {\n const link = event.currentTarget as HTMLElement;\n const id = this.#getAnchorId(link);\n if (!id) return;\n\n event.preventDefault();\n\n const targetElement = document.getElementById(id);\n if (!targetElement) return;\n\n const rootElement = this.#getRootElement();\n if (rootElement) {\n const containerRect = rootElement.getBoundingClientRect();\n const targetRect = targetElement.getBoundingClientRect();\n const scrollPosition =\n rootElement.scrollTop + (targetRect.top - containerRect.top) - this.offsetValue;\n\n rootElement.scrollTo({\n top: scrollPosition,\n behavior: \"smooth\",\n });\n } else {\n const targetRect = targetElement.getBoundingClientRect();\n const scrollPosition = window.scrollY + targetRect.top - this.offsetValue;\n\n window.scrollTo({\n top: scrollPosition,\n behavior: \"smooth\",\n });\n }\n }\n\n #getRootElement(): HTMLElement | null {\n if (!this.rootSelectorValue) return null;\n return document.querySelector(this.rootSelectorValue);\n }\n\n #initializeObserver(): void {\n if (this.#observer) {\n this.#observer.disconnect();\n this.#observer = null;\n }\n this.#intersectionStates.clear();\n this.#activeSectionId = \"\";\n\n if (this.linkTargets.length === 0) return;\n\n // Build the dynamic rootMargin string based on offset value\n const margin = this.rootMarginValue || `-${this.offsetValue}px 0px -80% 0px`;\n const rootEl = this.#getRootElement();\n\n this.#observer = new IntersectionObserver(this.#onIntersection, {\n root: rootEl,\n rootMargin: margin,\n threshold: [0, 0.2, 0.4, 0.6, 0.8, 1], // Multiple thresholds handle large sections safely\n });\n\n // Observe each target section mapped by the href anchors\n for (const link of this.linkTargets) {\n const id = this.#getAnchorId(link);\n if (!id) continue;\n\n const section = document.getElementById(id);\n if (section) {\n this.#observer.observe(section);\n }\n }\n }\n\n readonly #onIntersection = (entries: IntersectionObserverEntry[]): void => {\n // Ignore any callback delivered after teardown: the browser may flush a\n // final queued batch right after `disconnect()`, and a detached controller\n // must not mutate `aria-current` on (possibly cached) links.\n if (!this.#isConnected) return;\n\n for (const entry of entries) {\n const id = entry.target.id;\n if (!id) continue;\n\n this.#intersectionStates.set(id, {\n isIntersecting: entry.isIntersecting,\n top: entry.boundingClientRect.top,\n });\n }\n\n this.#evaluateActiveSection();\n };\n\n #evaluateActiveSection(): void {\n // The trigger line is `offset` px below the top of the scroll root. When a\n // nested `rootSelector` container is used it is not at the viewport top, so\n // the line must be measured from the container's current top — comparing the\n // viewport-based `boundingClientRect.top` against a bare `offset` would\n // otherwise pick the section nearest the viewport top, not the container's.\n const rootEl = this.#getRootElement();\n const triggerLine = (rootEl ? rootEl.getBoundingClientRect().top : 0) + this.offsetValue;\n\n let bestId = \"\";\n let closestTop = Number.MAX_VALUE;\n\n // Evaluate among all intersecting elements\n for (const [id, state] of this.#intersectionStates.entries()) {\n if (state.isIntersecting) {\n // Evaluate the one closest to the offset trigger line\n const distance = Math.abs(state.top - triggerLine);\n if (distance < closestTop) {\n closestTop = distance;\n bestId = id;\n }\n }\n }\n\n // Fallback: when no section currently intersects (e.g. scrolled past the\n // bottom, or between sections), pick the tracked section whose top is closest\n // to the trigger line so something stays highlighted.\n if (!bestId && this.#intersectionStates.size > 0) {\n let absoluteClosestId = \"\";\n let absoluteClosestTop = Number.MAX_VALUE;\n\n for (const [id, state] of this.#intersectionStates.entries()) {\n const distance = Math.abs(state.top - triggerLine);\n if (distance < absoluteClosestTop) {\n absoluteClosestTop = distance;\n absoluteClosestId = id;\n }\n }\n bestId = absoluteClosestId;\n }\n\n if (bestId && bestId !== this.#activeSectionId) {\n this.#activeSectionId = bestId;\n this.#syncActiveStates();\n }\n }\n\n #syncActiveStates(): void {\n const activeLink = this.linkTargets.find((l) => this.#getAnchorId(l) === this.#activeSectionId);\n\n for (const link of this.linkTargets) {\n const isActive = link === activeLink;\n if (isActive) {\n link.setAttribute(\"aria-current\", \"location\");\n } else {\n link.removeAttribute(\"aria-current\");\n }\n }\n\n if (activeLink) {\n this.dispatch(\"change\", { detail: { id: this.#activeSectionId, link: activeLink } });\n }\n }\n\n #getAnchorId(link: HTMLElement): string | null {\n const href = link.getAttribute(\"href\") || link.getAttribute(\"data-href\");\n if (!href?.startsWith(\"#\")) return null;\n return href.substring(1);\n }\n}\n"]}
@@ -0,0 +1,50 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless skeleton/placeholder manager. No dedicated APG pattern; it follows
5
+ * the `aria-busy` + show/hide practice.
6
+ *
7
+ * Markup contract (identifier: `stimeo--skeleton`):
8
+ * <div data-controller="stimeo--skeleton" aria-busy="true"
9
+ * data-stimeo--skeleton-min-duration-value="300"
10
+ * data-action="content:ready->stimeo--skeleton#ready">
11
+ * <div aria-hidden="true" data-stimeo--skeleton-target="placeholder"></div>
12
+ * <div hidden data-stimeo--skeleton-target="content"></div>
13
+ * </div>
14
+ *
15
+ * Starts in the loading state (placeholder shown, real content hidden, region
16
+ * `aria-busy="true"`); `ready` swaps to the content and clears busy. The
17
+ * placeholder is `aria-hidden` (decorative) so assistive tech never reads the
18
+ * skeleton. `minDuration` keeps the placeholder up long enough to avoid a flash
19
+ * when content arrives almost immediately.
20
+ *
21
+ * @remarks
22
+ * Behavior only — skeleton shapes/animation are the consumer's. The
23
+ * min-duration timer is owned by {@link SafeTimeout} and torn down on
24
+ * `disconnect()` (Turbo navigation included).
25
+ */
26
+ declare class SkeletonController extends Controller<HTMLElement> {
27
+ #private;
28
+ static targets: string[];
29
+ static values: {
30
+ minDuration: {
31
+ type: NumberConstructor;
32
+ default: number;
33
+ };
34
+ };
35
+ static actions: readonly ["ready", "reset"];
36
+ static events: readonly ["ready"];
37
+ readonly placeholderTarget: HTMLElement;
38
+ readonly contentTarget: HTMLElement;
39
+ readonly hasPlaceholderTarget: boolean;
40
+ readonly hasContentTarget: boolean;
41
+ minDurationValue: number;
42
+ connect(): void;
43
+ disconnect(): void;
44
+ /** Swaps to the real content. Honors `minDuration` to prevent a flash. */
45
+ ready(): void;
46
+ /** Returns to the loading state (e.g. a Turbo Stream re-fetch). */
47
+ reset(): void;
48
+ }
49
+
50
+ export { SkeletonController };
@@ -0,0 +1,125 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/skeleton_controller.ts
4
+
5
+ // src/utils/safe_timeout.ts
6
+ var TimerRegistry = class {
7
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
8
+ ids = /* @__PURE__ */ new Set();
9
+ /**
10
+ * Cancels a single tracked timer.
11
+ *
12
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
13
+ * registry), so callers can clear defensively without guarding.
14
+ */
15
+ clear(id) {
16
+ if (this.ids.delete(id)) {
17
+ this.cancel(id);
18
+ }
19
+ }
20
+ /**
21
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
22
+ * to guarantee no timer outlives the element.
23
+ */
24
+ clearAll() {
25
+ for (const id of this.ids) {
26
+ this.cancel(id);
27
+ }
28
+ this.ids.clear();
29
+ }
30
+ /** Number of timers currently tracked (pending). */
31
+ get size() {
32
+ return this.ids.size;
33
+ }
34
+ };
35
+ var SafeTimeout = class extends TimerRegistry {
36
+ /**
37
+ * Schedules `callback` after `delay` ms and returns the timer id.
38
+ *
39
+ * The id is removed from the registry automatically when the timeout fires,
40
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
41
+ */
42
+ set(callback, delay) {
43
+ const id = this.schedule(() => {
44
+ this.ids.delete(id);
45
+ callback();
46
+ }, delay);
47
+ this.ids.add(id);
48
+ return id;
49
+ }
50
+ schedule(callback, delay) {
51
+ return window.setTimeout(callback, delay);
52
+ }
53
+ cancel(id) {
54
+ window.clearTimeout(id);
55
+ }
56
+ };
57
+
58
+ // src/controllers/skeleton_controller.ts
59
+ var SkeletonController = class extends Controller {
60
+ static targets = ["placeholder", "content"];
61
+ static values = {
62
+ minDuration: { type: Number, default: 0 }
63
+ };
64
+ static actions = ["ready", "reset"];
65
+ static events = ["ready"];
66
+ #timers = new SafeTimeout();
67
+ /** Pending min-duration reveal timer id, or `null` when none is scheduled. */
68
+ #revealTimerId = null;
69
+ /** Epoch ms when the loading state began, used to enforce `minDuration`. */
70
+ #loadingSince = 0;
71
+ connect() {
72
+ if (this.#state !== "ready") {
73
+ this.#enterLoading();
74
+ }
75
+ }
76
+ disconnect() {
77
+ this.#timers.clearAll();
78
+ this.#revealTimerId = null;
79
+ }
80
+ /** Swaps to the real content. Honors `minDuration` to prevent a flash. */
81
+ ready() {
82
+ if (this.#state === "ready" || this.#revealTimerId !== null) return;
83
+ const remaining = this.minDurationValue - (Date.now() - this.#loadingSince);
84
+ if (remaining > 0) {
85
+ this.#revealTimerId = this.#timers.set(() => {
86
+ this.#revealTimerId = null;
87
+ this.#reveal();
88
+ }, remaining);
89
+ } else {
90
+ this.#reveal();
91
+ }
92
+ }
93
+ /** Returns to the loading state (e.g. a Turbo Stream re-fetch). */
94
+ reset() {
95
+ if (this.#revealTimerId !== null) {
96
+ this.#timers.clear(this.#revealTimerId);
97
+ this.#revealTimerId = null;
98
+ }
99
+ this.#enterLoading();
100
+ }
101
+ /** Shows the placeholder, hides content, and marks the region busy. */
102
+ #enterLoading() {
103
+ this.#loadingSince = Date.now();
104
+ if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = false;
105
+ if (this.hasContentTarget) this.contentTarget.hidden = true;
106
+ this.element.setAttribute("aria-busy", "true");
107
+ this.element.setAttribute("data-state", "loading");
108
+ }
109
+ /** Hides the placeholder, shows content, and clears the busy state. */
110
+ #reveal() {
111
+ if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = true;
112
+ if (this.hasContentTarget) this.contentTarget.hidden = false;
113
+ this.element.setAttribute("aria-busy", "false");
114
+ this.element.setAttribute("data-state", "ready");
115
+ this.dispatch("ready", { detail: {} });
116
+ }
117
+ /** Current lifecycle phase as reflected on `data-state`. */
118
+ get #state() {
119
+ return this.element.getAttribute("data-state") ?? "loading";
120
+ }
121
+ };
122
+
123
+ export { SkeletonController };
124
+ //# sourceMappingURL=skeleton_controller.js.map
125
+ //# sourceMappingURL=skeleton_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/skeleton_controller.ts"],"names":[],"mappings":"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","file":"skeleton_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless skeleton/placeholder manager. No dedicated APG pattern; it follows\n * the `aria-busy` + show/hide practice.\n *\n * Markup contract (identifier: `stimeo--skeleton`):\n * <div data-controller=\"stimeo--skeleton\" aria-busy=\"true\"\n * data-stimeo--skeleton-min-duration-value=\"300\"\n * data-action=\"content:ready->stimeo--skeleton#ready\">\n * <div aria-hidden=\"true\" data-stimeo--skeleton-target=\"placeholder\"></div>\n * <div hidden data-stimeo--skeleton-target=\"content\"></div>\n * </div>\n *\n * Starts in the loading state (placeholder shown, real content hidden, region\n * `aria-busy=\"true\"`); `ready` swaps to the content and clears busy. The\n * placeholder is `aria-hidden` (decorative) so assistive tech never reads the\n * skeleton. `minDuration` keeps the placeholder up long enough to avoid a flash\n * when content arrives almost immediately.\n *\n * @remarks\n * Behavior only — skeleton shapes/animation are the consumer's. The\n * min-duration timer is owned by {@link SafeTimeout} and torn down on\n * `disconnect()` (Turbo navigation included).\n */\nexport class SkeletonController extends Controller<HTMLElement> {\n static override targets = [\"placeholder\", \"content\"];\n static override values = {\n minDuration: { type: Number, default: 0 },\n };\n static actions = [\"ready\", \"reset\"] as const;\n static events = [\"ready\"] as const;\n\n declare readonly placeholderTarget: HTMLElement;\n declare readonly contentTarget: HTMLElement;\n declare readonly hasPlaceholderTarget: boolean;\n declare readonly hasContentTarget: boolean;\n\n declare minDurationValue: number;\n\n readonly #timers = new SafeTimeout();\n\n /** Pending min-duration reveal timer id, or `null` when none is scheduled. */\n #revealTimerId: number | null = null;\n /** Epoch ms when the loading state began, used to enforce `minDuration`. */\n #loadingSince = 0;\n\n override connect(): void {\n if (this.#state !== \"ready\") {\n this.#enterLoading();\n }\n }\n\n override disconnect(): void {\n this.#timers.clearAll();\n this.#revealTimerId = null;\n }\n\n /** Swaps to the real content. Honors `minDuration` to prevent a flash. */\n ready(): void {\n if (this.#state === \"ready\" || this.#revealTimerId !== null) return;\n const remaining = this.minDurationValue - (Date.now() - this.#loadingSince);\n if (remaining > 0) {\n this.#revealTimerId = this.#timers.set(() => {\n this.#revealTimerId = null;\n this.#reveal();\n }, remaining);\n } else {\n this.#reveal();\n }\n }\n\n /** Returns to the loading state (e.g. a Turbo Stream re-fetch). */\n reset(): void {\n if (this.#revealTimerId !== null) {\n this.#timers.clear(this.#revealTimerId);\n this.#revealTimerId = null;\n }\n this.#enterLoading();\n }\n\n /** Shows the placeholder, hides content, and marks the region busy. */\n #enterLoading(): void {\n this.#loadingSince = Date.now();\n if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = false;\n if (this.hasContentTarget) this.contentTarget.hidden = true;\n this.element.setAttribute(\"aria-busy\", \"true\");\n this.element.setAttribute(\"data-state\", \"loading\");\n }\n\n /** Hides the placeholder, shows content, and clears the busy state. */\n #reveal(): void {\n if (this.hasPlaceholderTarget) this.placeholderTarget.hidden = true;\n if (this.hasContentTarget) this.contentTarget.hidden = false;\n this.element.setAttribute(\"aria-busy\", \"false\");\n this.element.setAttribute(\"data-state\", \"ready\");\n this.dispatch(\"ready\", { detail: {} });\n }\n\n /** Current lifecycle phase as reflected on `data-state`. */\n get #state(): string {\n return this.element.getAttribute(\"data-state\") ?? \"loading\";\n }\n}\n"]}
@@ -0,0 +1,76 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless, accessible slider (single-thumb range) behavior.
5
+ *
6
+ * Markup contract (identifier: `stimeo--slider`):
7
+ * <div data-controller="stimeo--slider"
8
+ * data-stimeo--slider-min-value="0"
9
+ * data-stimeo--slider-max-value="100"
10
+ * data-stimeo--slider-step-value="1"
11
+ * data-stimeo--slider-value-value="40">
12
+ * <div data-stimeo--slider-target="track"
13
+ * data-action="pointerdown->stimeo--slider#onPointerDown">
14
+ * <div data-stimeo--slider-target="thumb" role="slider" tabindex="0"
15
+ * aria-valuemin="0" aria-valuemax="100" aria-valuenow="40"
16
+ * data-action="keydown->stimeo--slider#onKeydown"></div>
17
+ * </div>
18
+ * </div>
19
+ *
20
+ * Implements the WAI-ARIA APG **Slider** pattern. The current value is exposed
21
+ * to assistive tech via `aria-valuenow`/`aria-valuemin`/`aria-valuemax` on the
22
+ * thumb, and to the consumer's CSS via the `--stimeo--slider-fraction` custom
23
+ * property (a number in `[0, 1]`) set on the controller element — the library
24
+ * positions nothing itself.
25
+ *
26
+ * @remarks
27
+ * Behavior only. The consumer owns all layout (e.g. positioning the thumb from
28
+ * the fraction). Only the horizontal orientation is handled in this MVP.
29
+ *
30
+ * Behavior provided:
31
+ * - `ArrowRight`/`ArrowUp` increase and `ArrowLeft`/`ArrowDown` decrease by one
32
+ * step; `Home`/`End` jump to the min/max; `PageUp`/`PageDown` move by ten steps.
33
+ * - Pointer press/drag on the track sets the value from the pointer position.
34
+ */
35
+ declare class SliderController extends Controller<HTMLElement> {
36
+ #private;
37
+ static targets: string[];
38
+ static values: {
39
+ min: {
40
+ type: NumberConstructor;
41
+ default: number;
42
+ };
43
+ max: {
44
+ type: NumberConstructor;
45
+ default: number;
46
+ };
47
+ step: {
48
+ type: NumberConstructor;
49
+ default: number;
50
+ };
51
+ value: {
52
+ type: NumberConstructor;
53
+ default: number;
54
+ };
55
+ };
56
+ static actions: readonly ["onKeydown", "onPointerDown"];
57
+ static events: readonly ["change"];
58
+ readonly trackTarget: HTMLElement;
59
+ readonly thumbTarget: HTMLElement;
60
+ readonly hasTrackTarget: boolean;
61
+ readonly hasThumbTarget: boolean;
62
+ minValue: number;
63
+ maxValue: number;
64
+ stepValue: number;
65
+ valueValue: number;
66
+ /** Clamps the initial value and renders the starting position. */
67
+ connect(): void;
68
+ /** Cancels any active pointer drag so document listeners never leak. */
69
+ disconnect(): void;
70
+ /** Handles keyboard stepping per the APG slider model. */
71
+ onKeydown(event: KeyboardEvent): void;
72
+ /** Begins a pointer drag: sets the value and tracks subsequent movement. */
73
+ onPointerDown(event: PointerEvent): void;
74
+ }
75
+
76
+ export { SliderController };
@@ -0,0 +1,109 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/slider_controller.ts
4
+ var FRACTION_PROPERTY = "--stimeo--slider-fraction";
5
+ var SliderController = class extends Controller {
6
+ static targets = ["track", "thumb"];
7
+ static values = {
8
+ min: { type: Number, default: 0 },
9
+ max: { type: Number, default: 100 },
10
+ step: { type: Number, default: 1 },
11
+ value: { type: Number, default: 0 }
12
+ };
13
+ static actions = ["onKeydown", "onPointerDown"];
14
+ static events = ["change"];
15
+ /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
16
+ #dragAbort = null;
17
+ /** Clamps the initial value and renders the starting position. */
18
+ connect() {
19
+ this.#setValue(this.valueValue, { silent: true });
20
+ }
21
+ /** Cancels any active pointer drag so document listeners never leak. */
22
+ disconnect() {
23
+ this.#dragAbort?.abort();
24
+ this.#dragAbort = null;
25
+ }
26
+ /** Handles keyboard stepping per the APG slider model. */
27
+ onKeydown(event) {
28
+ const big = this.stepValue * 10;
29
+ let next = null;
30
+ switch (event.key) {
31
+ case "ArrowRight":
32
+ case "ArrowUp":
33
+ next = this.valueValue + this.stepValue;
34
+ break;
35
+ case "ArrowLeft":
36
+ case "ArrowDown":
37
+ next = this.valueValue - this.stepValue;
38
+ break;
39
+ case "PageUp":
40
+ next = this.valueValue + big;
41
+ break;
42
+ case "PageDown":
43
+ next = this.valueValue - big;
44
+ break;
45
+ case "Home":
46
+ next = this.minValue;
47
+ break;
48
+ case "End":
49
+ next = this.maxValue;
50
+ break;
51
+ default:
52
+ return;
53
+ }
54
+ event.preventDefault();
55
+ this.#setValue(next);
56
+ }
57
+ /** Begins a pointer drag: sets the value and tracks subsequent movement. */
58
+ onPointerDown(event) {
59
+ if (!this.hasTrackTarget) return;
60
+ event.preventDefault();
61
+ this.#updateFromClientX(event.clientX);
62
+ if (this.hasThumbTarget) this.thumbTarget.focus();
63
+ this.#dragAbort?.abort();
64
+ const abort = new AbortController();
65
+ this.#dragAbort = abort;
66
+ const onMove = (move) => this.#updateFromClientX(move.clientX);
67
+ const onUp = () => {
68
+ abort.abort();
69
+ this.#dragAbort = null;
70
+ };
71
+ document.addEventListener("pointermove", onMove, { signal: abort.signal });
72
+ document.addEventListener("pointerup", onUp, { signal: abort.signal });
73
+ document.addEventListener("pointercancel", onUp, { signal: abort.signal });
74
+ }
75
+ /** Maps a pointer X coordinate to a value using the track's geometry. */
76
+ #updateFromClientX(clientX) {
77
+ const rect = this.trackTarget.getBoundingClientRect();
78
+ if (rect.width === 0) return;
79
+ const fraction = (clientX - rect.left) / rect.width;
80
+ this.#setValue(this.minValue + fraction * (this.maxValue - this.minValue));
81
+ }
82
+ /**
83
+ * Clamps `raw` to `[min, max]`, snaps it to the nearest step, stores it, and
84
+ * reflects the new state on the thumb's ARIA attributes and the fraction
85
+ * custom property. Dispatches `change` (detail `{ value }`) on a real value
86
+ * change — symmetric with `range-slider` — unless `silent` (the initial
87
+ * connect render, which is not a user edit).
88
+ */
89
+ #setValue(raw, { silent = false } = {}) {
90
+ const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));
91
+ const stepped = this.stepValue > 0 ? Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue : clamped;
92
+ const value = Math.min(this.maxValue, Math.max(this.minValue, stepped));
93
+ const changed = value !== this.valueValue;
94
+ this.valueValue = value;
95
+ if (this.hasThumbTarget) {
96
+ this.thumbTarget.setAttribute("aria-valuemin", String(this.minValue));
97
+ this.thumbTarget.setAttribute("aria-valuemax", String(this.maxValue));
98
+ this.thumbTarget.setAttribute("aria-valuenow", String(value));
99
+ }
100
+ const span = this.maxValue - this.minValue;
101
+ const fraction = span > 0 ? (value - this.minValue) / span : 0;
102
+ this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));
103
+ if (changed && !silent) this.dispatch("change", { detail: { value } });
104
+ }
105
+ };
106
+
107
+ export { SliderController };
108
+ //# sourceMappingURL=slider_controller.js.map
109
+ //# sourceMappingURL=slider_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/slider_controller.ts"],"names":[],"mappings":"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","file":"slider_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** Name of the CSS custom property exposing the thumb position (0..1). */\nconst FRACTION_PROPERTY = \"--stimeo--slider-fraction\";\n\n/**\n * Headless, accessible slider (single-thumb range) behavior.\n *\n * Markup contract (identifier: `stimeo--slider`):\n * <div data-controller=\"stimeo--slider\"\n * data-stimeo--slider-min-value=\"0\"\n * data-stimeo--slider-max-value=\"100\"\n * data-stimeo--slider-step-value=\"1\"\n * data-stimeo--slider-value-value=\"40\">\n * <div data-stimeo--slider-target=\"track\"\n * data-action=\"pointerdown->stimeo--slider#onPointerDown\">\n * <div data-stimeo--slider-target=\"thumb\" role=\"slider\" tabindex=\"0\"\n * aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"40\"\n * data-action=\"keydown->stimeo--slider#onKeydown\"></div>\n * </div>\n * </div>\n *\n * Implements the WAI-ARIA APG **Slider** pattern. The current value is exposed\n * to assistive tech via `aria-valuenow`/`aria-valuemin`/`aria-valuemax` on the\n * thumb, and to the consumer's CSS via the `--stimeo--slider-fraction` custom\n * property (a number in `[0, 1]`) set on the controller element — the library\n * positions nothing itself.\n *\n * @remarks\n * Behavior only. The consumer owns all layout (e.g. positioning the thumb from\n * the fraction). Only the horizontal orientation is handled in this MVP.\n *\n * Behavior provided:\n * - `ArrowRight`/`ArrowUp` increase and `ArrowLeft`/`ArrowDown` decrease by one\n * step; `Home`/`End` jump to the min/max; `PageUp`/`PageDown` move by ten steps.\n * - Pointer press/drag on the track sets the value from the pointer position.\n */\nexport class SliderController extends Controller<HTMLElement> {\n static override targets = [\"track\", \"thumb\"];\n static override values = {\n min: { type: Number, default: 0 },\n max: { type: Number, default: 100 },\n step: { type: Number, default: 1 },\n value: { type: Number, default: 0 },\n };\n static actions = [\"onKeydown\", \"onPointerDown\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly trackTarget: HTMLElement;\n declare readonly thumbTarget: HTMLElement;\n declare readonly hasTrackTarget: boolean;\n declare readonly hasThumbTarget: boolean;\n declare minValue: number;\n declare maxValue: number;\n declare stepValue: number;\n declare valueValue: number;\n\n /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */\n #dragAbort: AbortController | null = null;\n\n /** Clamps the initial value and renders the starting position. */\n override connect(): void {\n this.#setValue(this.valueValue, { silent: true });\n }\n\n /** Cancels any active pointer drag so document listeners never leak. */\n override disconnect(): void {\n this.#dragAbort?.abort();\n this.#dragAbort = null;\n }\n\n /** Handles keyboard stepping per the APG slider model. */\n onKeydown(event: KeyboardEvent): void {\n const big = this.stepValue * 10;\n let next: number | null = null;\n switch (event.key) {\n case \"ArrowRight\":\n case \"ArrowUp\":\n next = this.valueValue + this.stepValue;\n break;\n case \"ArrowLeft\":\n case \"ArrowDown\":\n next = this.valueValue - this.stepValue;\n break;\n case \"PageUp\":\n next = this.valueValue + big;\n break;\n case \"PageDown\":\n next = this.valueValue - big;\n break;\n case \"Home\":\n next = this.minValue;\n break;\n case \"End\":\n next = this.maxValue;\n break;\n default:\n return;\n }\n event.preventDefault();\n this.#setValue(next);\n }\n\n /** Begins a pointer drag: sets the value and tracks subsequent movement. */\n onPointerDown(event: PointerEvent): void {\n if (!this.hasTrackTarget) return;\n event.preventDefault();\n this.#updateFromClientX(event.clientX);\n if (this.hasThumbTarget) this.thumbTarget.focus();\n\n this.#dragAbort?.abort();\n const abort = new AbortController();\n this.#dragAbort = abort;\n const onMove = (move: PointerEvent): void => this.#updateFromClientX(move.clientX);\n const onUp = (): void => {\n abort.abort();\n this.#dragAbort = null;\n };\n document.addEventListener(\"pointermove\", onMove, { signal: abort.signal });\n document.addEventListener(\"pointerup\", onUp, { signal: abort.signal });\n // pointercancel fires when the gesture is interrupted (OS gesture, scroll\n // takeover, device switch); clean up the same way so no listener leaks.\n document.addEventListener(\"pointercancel\", onUp, { signal: abort.signal });\n }\n\n /** Maps a pointer X coordinate to a value using the track's geometry. */\n #updateFromClientX(clientX: number): void {\n const rect = this.trackTarget.getBoundingClientRect();\n if (rect.width === 0) return;\n const fraction = (clientX - rect.left) / rect.width;\n this.#setValue(this.minValue + fraction * (this.maxValue - this.minValue));\n }\n\n /**\n * Clamps `raw` to `[min, max]`, snaps it to the nearest step, stores it, and\n * reflects the new state on the thumb's ARIA attributes and the fraction\n * custom property. Dispatches `change` (detail `{ value }`) on a real value\n * change — symmetric with `range-slider` — unless `silent` (the initial\n * connect render, which is not a user edit).\n */\n #setValue(raw: number, { silent = false }: { silent?: boolean } = {}): void {\n const clamped = Math.min(this.maxValue, Math.max(this.minValue, raw));\n const stepped =\n this.stepValue > 0\n ? Math.round((clamped - this.minValue) / this.stepValue) * this.stepValue + this.minValue\n : clamped;\n const value = Math.min(this.maxValue, Math.max(this.minValue, stepped));\n const changed = value !== this.valueValue;\n this.valueValue = value;\n\n if (this.hasThumbTarget) {\n this.thumbTarget.setAttribute(\"aria-valuemin\", String(this.minValue));\n this.thumbTarget.setAttribute(\"aria-valuemax\", String(this.maxValue));\n this.thumbTarget.setAttribute(\"aria-valuenow\", String(value));\n }\n\n const span = this.maxValue - this.minValue;\n const fraction = span > 0 ? (value - this.minValue) / span : 0;\n this.element.style.setProperty(FRACTION_PROPERTY, String(fraction));\n\n if (changed && !silent) this.dispatch(\"change\", { detail: { value } });\n }\n}\n"]}