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,100 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/stick_to_bottom_controller.ts
4
+ var countElements = (nodes) => {
5
+ let n = 0;
6
+ for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;
7
+ return n;
8
+ };
9
+ var StickToBottomController = class extends Controller {
10
+ static targets = ["content"];
11
+ static values = {
12
+ threshold: { type: Number, default: 80 },
13
+ behavior: { type: String, default: "auto" }
14
+ };
15
+ static actions = ["scrollToBottom"];
16
+ static events = ["pin", "new"];
17
+ #observer = null;
18
+ #pinned = false;
19
+ #onScroll = () => this.#updatePinned();
20
+ connect() {
21
+ this.#pinned = this.#isPinned();
22
+ this.#reflectPinned();
23
+ this.element.addEventListener("scroll", this.#onScroll, { passive: true });
24
+ if (typeof MutationObserver !== "undefined") {
25
+ this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));
26
+ this.#observer.observe(this.#watched(), { childList: true });
27
+ }
28
+ }
29
+ disconnect() {
30
+ this.element.removeEventListener("scroll", this.#onScroll);
31
+ this.#observer?.disconnect();
32
+ this.#observer = null;
33
+ }
34
+ /** Jumps to the bottom and re-pins (wired to a "new messages" button). */
35
+ scrollToBottom() {
36
+ this.#scrollToBottom();
37
+ this.element.removeAttribute("data-has-new");
38
+ if (!this.#pinned) {
39
+ this.#pinned = true;
40
+ this.element.setAttribute("data-pinned", "true");
41
+ this.dispatch("pin", { detail: { pinned: true } });
42
+ }
43
+ }
44
+ /** Follows appended children while pinned; otherwise flags new content. */
45
+ #onMutations(mutations) {
46
+ let added = 0;
47
+ for (const mutation of mutations) added += countElements(mutation.addedNodes);
48
+ if (added === 0) return;
49
+ if (this.#pinned) {
50
+ this.#scrollToBottom();
51
+ } else {
52
+ this.element.setAttribute("data-has-new", "true");
53
+ this.dispatch("new", { detail: { count: added } });
54
+ }
55
+ }
56
+ /** Recomputes pinned from the scroll position and reflects it on a transition. */
57
+ #updatePinned() {
58
+ const pinned = this.#isPinned();
59
+ if (pinned === this.#pinned) return;
60
+ this.#pinned = pinned;
61
+ this.#reflectPinned();
62
+ this.dispatch("pin", { detail: { pinned } });
63
+ }
64
+ /** Mirrors the current `#pinned` onto the state hooks (clearing has-new once pinned). */
65
+ #reflectPinned() {
66
+ if (this.#pinned) {
67
+ this.element.setAttribute("data-pinned", "true");
68
+ this.element.removeAttribute("data-has-new");
69
+ } else {
70
+ this.element.removeAttribute("data-pinned");
71
+ }
72
+ }
73
+ #isPinned() {
74
+ const el = this.element;
75
+ return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;
76
+ }
77
+ #scrollToBottom() {
78
+ const top = this.element.scrollHeight;
79
+ if (typeof this.element.scrollTo === "function") {
80
+ this.element.scrollTo({ top, behavior: this.#behavior() });
81
+ } else {
82
+ this.element.scrollTop = top;
83
+ }
84
+ }
85
+ /** The append-watched element: the `content` target, or the container itself. */
86
+ #watched() {
87
+ return this.hasContentTarget ? this.contentTarget : this.element;
88
+ }
89
+ #behavior() {
90
+ if (this.#prefersReducedMotion()) return "auto";
91
+ return this.behaviorValue === "smooth" ? "smooth" : "auto";
92
+ }
93
+ #prefersReducedMotion() {
94
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
95
+ }
96
+ };
97
+
98
+ export { StickToBottomController };
99
+ //# sourceMappingURL=stick_to_bottom_controller.js.map
100
+ //# sourceMappingURL=stick_to_bottom_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/stick_to_bottom_controller.ts"],"names":[],"mappings":"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","file":"stick_to_bottom_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** Counts the element nodes in an added-node list (text nodes are ignored). */\nconst countElements = (nodes: NodeList): number => {\n let n = 0;\n for (const node of nodes) if (node.nodeType === Node.ELEMENT_NODE) n += 1;\n return n;\n};\n\n/**\n * Headless **stick-to-bottom**: auto-follows new content to the bottom of a scroll\n * container (a chat log, a live console) — but only while the user is already near the\n * bottom; if they have scrolled up to read, it holds position and flags that new content\n * arrived instead (no APG pattern; honors WCAG 2.3.3 via `prefers-reduced-motion` and\n * never steals focus).\n *\n * Markup contract (identifier: `stimeo--stick-to-bottom`):\n * <div data-controller=\"stimeo--stick-to-bottom\"\n * data-stimeo--stick-to-bottom-threshold-value=\"80\" style=\"overflow:auto\">\n * <ul data-stimeo--stick-to-bottom-target=\"content\"><!-- Turbo Stream appends --></ul>\n * </div>\n *\n * The container is \"pinned\" while its distance from the bottom is within `threshold`. A\n * `MutationObserver` on `content` (or the element) reacts to appended children: while\n * pinned it scrolls to the bottom; while unpinned it sets `data-has-new` and emits `new`.\n * Scrolling recomputes pinned and reflects `data-pinned`, emitting `pin` on change; the\n * `scrollToBottom` action jumps back down (a \"new messages\" button).\n *\n * @remarks\n * The `MutationObserver` watches `childList` only (not `subtree`), so it follows\n * direct appends to `content` (or the element). Appends made deeper inside a nested\n * wrapper are not detected — keep messages as direct children, or call the public\n * `scrollToBottom` action after such inserts.\n *\n * Behavior only — it does not add content (Turbo Stream / the consumer does) and is the\n * minimal follow primitive, not a full chat UI (no virtualization / message input). It is\n * the lightweight member of the scroll family. State is derived from the scroll position\n * each pass (no module-scope state), so `connect()` re-syncs after a Turbo Stream insert;\n * `behavior` falls back to `auto` under reduced motion; auto-scroll never moves focus; the\n * observer and the passive scroll listener are released on `disconnect()` (Turbo\n * navigation included).\n */\nexport class StickToBottomController extends Controller<HTMLElement> {\n static override targets = [\"content\"];\n static override values = {\n threshold: { type: Number, default: 80 },\n behavior: { type: String, default: \"auto\" },\n };\n static actions = [\"scrollToBottom\"] as const;\n static events = [\"pin\", \"new\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n\n declare thresholdValue: number;\n declare behaviorValue: string;\n\n #observer: MutationObserver | null = null;\n #pinned = false;\n\n readonly #onScroll = (): void => this.#updatePinned();\n\n override connect(): void {\n this.#pinned = this.#isPinned();\n // Re-sync the hooks from the current geometry — a Turbo cache restore may bring back a\n // stale data-pinned / data-has-new that no longer matches the scroll position.\n this.#reflectPinned();\n\n this.element.addEventListener(\"scroll\", this.#onScroll, { passive: true });\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver((mutations) => this.#onMutations(mutations));\n this.#observer.observe(this.#watched(), { childList: true });\n }\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"scroll\", this.#onScroll);\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /** Jumps to the bottom and re-pins (wired to a \"new messages\" button). */\n scrollToBottom(): void {\n this.#scrollToBottom();\n this.element.removeAttribute(\"data-has-new\");\n if (!this.#pinned) {\n this.#pinned = true;\n this.element.setAttribute(\"data-pinned\", \"true\");\n this.dispatch(\"pin\", { detail: { pinned: true } });\n }\n }\n\n /** Follows appended children while pinned; otherwise flags new content. */\n #onMutations(mutations: MutationRecord[]): void {\n let added = 0;\n for (const mutation of mutations) added += countElements(mutation.addedNodes);\n if (added === 0) return;\n\n if (this.#pinned) {\n this.#scrollToBottom();\n } else {\n this.element.setAttribute(\"data-has-new\", \"true\");\n this.dispatch(\"new\", { detail: { count: added } });\n }\n }\n\n /** Recomputes pinned from the scroll position and reflects it on a transition. */\n #updatePinned(): void {\n const pinned = this.#isPinned();\n if (pinned === this.#pinned) return;\n this.#pinned = pinned;\n this.#reflectPinned();\n this.dispatch(\"pin\", { detail: { pinned } });\n }\n\n /** Mirrors the current `#pinned` onto the state hooks (clearing has-new once pinned). */\n #reflectPinned(): void {\n if (this.#pinned) {\n this.element.setAttribute(\"data-pinned\", \"true\");\n this.element.removeAttribute(\"data-has-new\"); // caught up with the bottom\n } else {\n this.element.removeAttribute(\"data-pinned\");\n }\n }\n\n #isPinned(): boolean {\n const el = this.element;\n return el.scrollHeight - el.clientHeight - el.scrollTop <= this.thresholdValue;\n }\n\n #scrollToBottom(): void {\n const top = this.element.scrollHeight;\n if (typeof this.element.scrollTo === \"function\") {\n this.element.scrollTo({ top, behavior: this.#behavior() });\n } else {\n this.element.scrollTop = top;\n }\n }\n\n /** The append-watched element: the `content` target, or the container itself. */\n #watched(): HTMLElement {\n return this.hasContentTarget ? this.contentTarget : this.element;\n }\n\n #behavior(): ScrollBehavior {\n if (this.#prefersReducedMotion()) return \"auto\";\n return this.behaviorValue === \"smooth\" ? \"smooth\" : \"auto\";\n }\n\n #prefersReducedMotion(): boolean {\n return (\n typeof window.matchMedia === \"function\" &&\n window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n );\n }\n}\n"]}
@@ -0,0 +1,54 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless **Sticky State Observer**: detects whether a `position: sticky`
5
+ * element is currently stuck and publishes it as `data-stuck`. No APG widget — a
6
+ * pure state-detection utility. Detection uses an {@link IntersectionObserver}
7
+ * and a sentinel placed just before the sticky element, avoiding per-frame scroll
8
+ * math.
9
+ *
10
+ * Markup contract (identifier: `stimeo--sticky-observer`):
11
+ * <div data-controller="stimeo--sticky-observer">
12
+ * <div data-stimeo--sticky-observer-target="sentinel"
13
+ * aria-hidden="true" style="height: 1px;"></div>
14
+ * <header data-stimeo--sticky-observer-target="element"
15
+ * style="position: sticky; top: 0;">Site heading</header>
16
+ * <main>…</main>
17
+ * </div>
18
+ *
19
+ * When the sentinel scrolls out past the top of the viewport (or `rootSelector`
20
+ * container), the sticky element is considered stuck and `data-stuck="true"` is
21
+ * set; otherwise `false`. The `change` event fires on each transition.
22
+ *
23
+ * @remarks
24
+ * Behavior only — `position: sticky`, shadows, and shrink effects are the
25
+ * consumer's CSS (`[data-stuck="true"] { … }`). `data-stuck` is a visual hook
26
+ * only: it carries no ARIA role/state. `offset` feeds a negative top `rootMargin`
27
+ * and must match the sticky element's CSS `top`. The observer is disconnected on
28
+ * `disconnect()` (Turbo navigation included).
29
+ */
30
+ declare class StickyObserverController extends Controller<HTMLElement> {
31
+ #private;
32
+ static targets: string[];
33
+ static values: {
34
+ rootSelector: {
35
+ type: StringConstructor;
36
+ default: string;
37
+ };
38
+ offset: {
39
+ type: NumberConstructor;
40
+ default: number;
41
+ };
42
+ };
43
+ static events: readonly ["change"];
44
+ readonly sentinelTarget: HTMLElement;
45
+ readonly elementTarget: HTMLElement;
46
+ readonly hasSentinelTarget: boolean;
47
+ readonly hasElementTarget: boolean;
48
+ rootSelectorValue: string;
49
+ offsetValue: number;
50
+ connect(): void;
51
+ disconnect(): void;
52
+ }
53
+
54
+ export { StickyObserverController };
@@ -0,0 +1,53 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/sticky_observer_controller.ts
4
+ var StickyObserverController = class extends Controller {
5
+ static targets = ["sentinel", "element"];
6
+ static values = {
7
+ rootSelector: { type: String, default: "" },
8
+ offset: { type: Number, default: 0 }
9
+ };
10
+ static events = ["change"];
11
+ #observer = null;
12
+ /** Guards against a queued callback mutating state after teardown. */
13
+ #active = false;
14
+ /** Last reported stuck state, so `change` fires only on transitions. */
15
+ #stuck = null;
16
+ #onIntersect = (entries) => {
17
+ if (!this.#active) return;
18
+ const entry = entries[entries.length - 1];
19
+ if (!entry) return;
20
+ this.#setStuck(!entry.isIntersecting);
21
+ };
22
+ connect() {
23
+ if (!this.hasSentinelTarget) return;
24
+ this.#active = true;
25
+ if (typeof IntersectionObserver === "undefined") return;
26
+ const root = this.rootSelectorValue ? document.querySelector(this.rootSelectorValue) : null;
27
+ this.#observer = new IntersectionObserver(this.#onIntersect, {
28
+ root,
29
+ rootMargin: `-${this.offsetValue}px 0px 0px 0px`,
30
+ threshold: [0]
31
+ });
32
+ this.#observer.observe(this.sentinelTarget);
33
+ }
34
+ disconnect() {
35
+ this.#active = false;
36
+ this.#observer?.disconnect();
37
+ this.#observer = null;
38
+ this.#stuck = null;
39
+ }
40
+ /** Reflects the stuck state onto the sticky element and emits `change`. */
41
+ #setStuck(next) {
42
+ if (next === this.#stuck) return;
43
+ this.#stuck = next;
44
+ if (this.hasElementTarget) {
45
+ this.elementTarget.setAttribute("data-stuck", next ? "true" : "false");
46
+ }
47
+ this.dispatch("change", { detail: { stuck: next } });
48
+ }
49
+ };
50
+
51
+ export { StickyObserverController };
52
+ //# sourceMappingURL=sticky_observer_controller.js.map
53
+ //# sourceMappingURL=sticky_observer_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/sticky_observer_controller.ts"],"names":[],"mappings":";;;AA6BO,IAAM,wBAAA,GAAN,cAAuC,UAAA,CAAwB;AAAA,EACpE,OAAgB,OAAA,GAAU,CAAC,UAAA,EAAY,SAAS,CAAA;AAAA,EAChD,OAAgB,MAAA,GAAS;AAAA,IACvB,YAAA,EAAc,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IAC1C,MAAA,EAAQ,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA;AAAE,GACrC;AAAA,EACA,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA,EAUzB,SAAA,GAAyC,IAAA;AAAA;AAAA,EAEzC,OAAA,GAAU,KAAA;AAAA;AAAA,EAEV,MAAA,GAAyB,IAAA;AAAA,EAEhB,YAAA,GAAe,CAAC,OAAA,KAA+C;AACtE,IAAA,IAAI,CAAC,KAAK,OAAA,EAAS;AACnB,IAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,OAAA,CAAQ,MAAA,GAAS,CAAC,CAAA;AACxC,IAAA,IAAI,CAAC,KAAA,EAAO;AAGZ,IAAA,IAAA,CAAK,SAAA,CAAU,CAAC,KAAA,CAAM,cAAc,CAAA;AAAA,EACtC,CAAA;AAAA,EAES,OAAA,GAAgB;AACvB,IAAA,IAAI,CAAC,KAAK,iBAAA,EAAmB;AAC7B,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AAEf,IAAA,IAAI,OAAO,yBAAyB,WAAA,EAAa;AACjD,IAAA,MAAM,OAAO,IAAA,CAAK,iBAAA,GACd,SAAS,aAAA,CAA2B,IAAA,CAAK,iBAAiB,CAAA,GAC1D,IAAA;AAEJ,IAAA,IAAA,CAAK,SAAA,GAAY,IAAI,oBAAA,CAAqB,IAAA,CAAK,YAAA,EAAc;AAAA,MAC3D,IAAA;AAAA,MACA,UAAA,EAAY,CAAA,CAAA,EAAI,IAAA,CAAK,WAAW,CAAA,cAAA,CAAA;AAAA,MAChC,SAAA,EAAW,CAAC,CAAC;AAAA,KACd,CAAA;AACD,IAAA,IAAA,CAAK,SAAA,CAAU,OAAA,CAAQ,IAAA,CAAK,cAAc,CAAA;AAAA,EAC5C;AAAA,EAES,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,IAAA,IAAA,CAAK,WAAW,UAAA,EAAW;AAC3B,IAAA,IAAA,CAAK,SAAA,GAAY,IAAA;AACjB,IAAA,IAAA,CAAK,MAAA,GAAS,IAAA;AAAA,EAChB;AAAA;AAAA,EAGA,UAAU,IAAA,EAAqB;AAC7B,IAAA,IAAI,IAAA,KAAS,KAAK,MAAA,EAAQ;AAC1B,IAAA,IAAA,CAAK,MAAA,GAAS,IAAA;AACd,IAAA,IAAI,KAAK,gBAAA,EAAkB;AACzB,MAAA,IAAA,CAAK,aAAA,CAAc,YAAA,CAAa,YAAA,EAAc,IAAA,GAAO,SAAS,OAAO,CAAA;AAAA,IACvE;AACA,IAAA,IAAA,CAAK,QAAA,CAAS,UAAU,EAAE,MAAA,EAAQ,EAAE,KAAA,EAAO,IAAA,IAAQ,CAAA;AAAA,EACrD;AACF","file":"sticky_observer_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **Sticky State Observer**: detects whether a `position: sticky`\n * element is currently stuck and publishes it as `data-stuck`. No APG widget — a\n * pure state-detection utility. Detection uses an {@link IntersectionObserver}\n * and a sentinel placed just before the sticky element, avoiding per-frame scroll\n * math.\n *\n * Markup contract (identifier: `stimeo--sticky-observer`):\n * <div data-controller=\"stimeo--sticky-observer\">\n * <div data-stimeo--sticky-observer-target=\"sentinel\"\n * aria-hidden=\"true\" style=\"height: 1px;\"></div>\n * <header data-stimeo--sticky-observer-target=\"element\"\n * style=\"position: sticky; top: 0;\">Site heading</header>\n * <main>…</main>\n * </div>\n *\n * When the sentinel scrolls out past the top of the viewport (or `rootSelector`\n * container), the sticky element is considered stuck and `data-stuck=\"true\"` is\n * set; otherwise `false`. The `change` event fires on each transition.\n *\n * @remarks\n * Behavior only — `position: sticky`, shadows, and shrink effects are the\n * consumer's CSS (`[data-stuck=\"true\"] { … }`). `data-stuck` is a visual hook\n * only: it carries no ARIA role/state. `offset` feeds a negative top `rootMargin`\n * and must match the sticky element's CSS `top`. The observer is disconnected on\n * `disconnect()` (Turbo navigation included).\n */\nexport class StickyObserverController extends Controller<HTMLElement> {\n static override targets = [\"sentinel\", \"element\"];\n static override values = {\n rootSelector: { type: String, default: \"\" },\n offset: { type: Number, default: 0 },\n };\n static events = [\"change\"] as const;\n\n declare readonly sentinelTarget: HTMLElement;\n declare readonly elementTarget: HTMLElement;\n declare readonly hasSentinelTarget: boolean;\n declare readonly hasElementTarget: boolean;\n\n declare rootSelectorValue: string;\n declare offsetValue: number;\n\n #observer: IntersectionObserver | null = null;\n /** Guards against a queued callback mutating state after teardown. */\n #active = false;\n /** Last reported stuck state, so `change` fires only on transitions. */\n #stuck: boolean | null = null;\n\n readonly #onIntersect = (entries: IntersectionObserverEntry[]): void => {\n if (!this.#active) return;\n const entry = entries[entries.length - 1];\n if (!entry) return;\n // The sentinel sits above the sticky element; once it is no longer\n // intersecting the (top-inset) root, the sticky element has stuck.\n this.#setStuck(!entry.isIntersecting);\n };\n\n override connect(): void {\n if (!this.hasSentinelTarget) return;\n this.#active = true;\n\n if (typeof IntersectionObserver === \"undefined\") return;\n const root = this.rootSelectorValue\n ? document.querySelector<HTMLElement>(this.rootSelectorValue)\n : null;\n\n this.#observer = new IntersectionObserver(this.#onIntersect, {\n root,\n rootMargin: `-${this.offsetValue}px 0px 0px 0px`,\n threshold: [0],\n });\n this.#observer.observe(this.sentinelTarget);\n }\n\n override disconnect(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#stuck = null;\n }\n\n /** Reflects the stuck state onto the sticky element and emits `change`. */\n #setStuck(next: boolean): void {\n if (next === this.#stuck) return;\n this.#stuck = next;\n if (this.hasElementTarget) {\n this.elementTarget.setAttribute(\"data-stuck\", next ? \"true\" : \"false\");\n }\n this.dispatch(\"change\", { detail: { stuck: next } });\n }\n}\n"]}
@@ -0,0 +1,66 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /** Submit-capable controls this controller can disable. */
4
+ type SubmitButton = HTMLButtonElement | HTMLInputElement;
5
+ /**
6
+ * Headless "submit once" behavior: disables the form's submit button(s) on submit
7
+ * to prevent a double submission, optionally swaps in a busy label, and restores
8
+ * on completion. The Headless superset of Rails' `disable_with` (no dedicated APG
9
+ * pattern; follows the WCAG "status messages" practice via `aria-busy`).
10
+ *
11
+ * Markup contract (identifier: `stimeo--submit-once`):
12
+ * <form data-controller="stimeo--submit-once"
13
+ * data-stimeo--submit-once-busy-label-value="Submitting…"
14
+ * data-action="submit->stimeo--submit-once#start">
15
+ * <button type="submit" data-stimeo--submit-once-target="submit">Send</button>
16
+ * </form>
17
+ *
18
+ * On submit it disables every `submit` target (or, with none, the form's native
19
+ * `button[type=submit]` / `input[type=submit]`), sets `aria-busy` on them and
20
+ * `data-submitting` on the form, and swaps the **triggering** button's visible
21
+ * label for the busy label (per-button `data-submit-once-busy-label` overrides the
22
+ * form's `busyLabel` Value). It restores on Turbo's `turbo:submit-end`, or after
23
+ * `timeout` ms, re-enabling the buttons and putting the labels back.
24
+ *
25
+ * @remarks
26
+ * Behavior only — no spinner is drawn (pair with {@link "spinner"}). The submit-end
27
+ * listener and the timeout are torn down on `disconnect()` (Turbo navigation
28
+ * included), and `connect()` clears any stale busy state parked in a restored cache
29
+ * snapshot so a button is never left disabled. Non-Turbo caveat: the buttons are
30
+ * disabled synchronously inside the `submit` handler, and a plain HTML submission
31
+ * builds its entry list *after* that event — so a disabled submitter's `name`/`value`
32
+ * is excluded from the payload. Turbo is unaffected (it appends the submitter's
33
+ * name/value from attributes when building `FormData`); mirror the value into a
34
+ * hidden field when a non-Turbo form depends on it.
35
+ */
36
+ declare class SubmitOnceController extends Controller<HTMLElement> {
37
+ #private;
38
+ static targets: string[];
39
+ static values: {
40
+ busyLabel: {
41
+ type: StringConstructor;
42
+ default: string;
43
+ };
44
+ timeout: {
45
+ type: NumberConstructor;
46
+ default: number;
47
+ };
48
+ restoreFocus: {
49
+ type: BooleanConstructor;
50
+ default: boolean;
51
+ };
52
+ };
53
+ static actions: readonly ["start"];
54
+ static events: readonly ["start", "end"];
55
+ readonly submitTargets: SubmitButton[];
56
+ readonly hasSubmitTarget: boolean;
57
+ busyLabelValue: string;
58
+ timeoutValue: number;
59
+ restoreFocusValue: boolean;
60
+ connect(): void;
61
+ disconnect(): void;
62
+ /** Enters the busy state for the submission started by `event`. */
63
+ start(event: SubmitEvent): void;
64
+ }
65
+
66
+ export { SubmitOnceController };
@@ -0,0 +1,206 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/submit_once_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/submit_once_controller.ts
59
+ var ORIGINAL_LABEL = "data-submit-once-original-label";
60
+ var DISABLED_MARKER = "data-submit-once-disabled";
61
+ var BUTTON_BUSY_LABEL = "data-submit-once-busy-label";
62
+ var SubmitOnceController = class extends Controller {
63
+ static targets = ["submit"];
64
+ static values = {
65
+ busyLabel: { type: String, default: "" },
66
+ timeout: { type: Number, default: 0 },
67
+ restoreFocus: { type: Boolean, default: false }
68
+ };
69
+ static actions = ["start"];
70
+ static events = ["start", "end"];
71
+ #timeouts = new SafeTimeout();
72
+ #timeoutId = null;
73
+ #busy = false;
74
+ #submitter = null;
75
+ #onSubmitEnd = (event) => {
76
+ const success = event.detail?.success;
77
+ this.#restore(success);
78
+ };
79
+ connect() {
80
+ this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
81
+ this.#clearStaleBusy();
82
+ }
83
+ disconnect() {
84
+ this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
85
+ this.#clearTimeout();
86
+ }
87
+ /** Enters the busy state for the submission started by `event`. */
88
+ start(event) {
89
+ if (this.#busy) return;
90
+ this.#busy = true;
91
+ const buttons = this.#buttons;
92
+ const submitter = this.#resolveSubmitter(event, buttons);
93
+ this.#submitter = submitter;
94
+ for (const button of buttons) {
95
+ this.#enterBusy(button, button === submitter);
96
+ }
97
+ this.element.setAttribute("data-submitting", "true");
98
+ this.element.setAttribute("aria-busy", "true");
99
+ this.dispatch("start", { detail: {} });
100
+ if (this.timeoutValue > 0) {
101
+ this.#timeoutId = this.#timeouts.set(() => this.#restore(false), this.timeoutValue);
102
+ }
103
+ }
104
+ /** Restores the non-busy state, re-enabling buttons and putting labels back. */
105
+ #restore(success) {
106
+ if (!this.#busy) return;
107
+ this.#busy = false;
108
+ this.#clearTimeout();
109
+ for (const button of this.#buttons) {
110
+ this.#exitBusy(button);
111
+ }
112
+ this.element.removeAttribute("data-submitting");
113
+ this.element.removeAttribute("aria-busy");
114
+ this.dispatch("end", { detail: { success } });
115
+ if (this.restoreFocusValue && this.#submitter) {
116
+ this.#submitter.focus();
117
+ }
118
+ this.#submitter = null;
119
+ }
120
+ /** Disables a button, marks it busy, and swaps the trigger's label. */
121
+ #enterBusy(button, isTrigger) {
122
+ if (!button.disabled) {
123
+ button.disabled = true;
124
+ button.setAttribute(DISABLED_MARKER, "true");
125
+ button.setAttribute("aria-busy", "true");
126
+ }
127
+ if (!isTrigger) return;
128
+ const label = button.getAttribute(BUTTON_BUSY_LABEL) ?? this.busyLabelValue;
129
+ if (label.length === 0) return;
130
+ if (!button.hasAttribute(ORIGINAL_LABEL)) {
131
+ button.setAttribute(ORIGINAL_LABEL, this.#getLabel(button));
132
+ }
133
+ this.#setLabel(button, label);
134
+ }
135
+ /** Re-enables a button we disabled and restores its parked label, if any. */
136
+ #exitBusy(button) {
137
+ if (button.hasAttribute(DISABLED_MARKER)) {
138
+ button.disabled = false;
139
+ button.removeAttribute(DISABLED_MARKER);
140
+ button.removeAttribute("aria-busy");
141
+ }
142
+ const original = button.getAttribute(ORIGINAL_LABEL);
143
+ if (original !== null) {
144
+ this.#setLabel(button, original);
145
+ button.removeAttribute(ORIGINAL_LABEL);
146
+ }
147
+ }
148
+ /** Resets busy state left in a restored cache snapshot without firing events. */
149
+ #clearStaleBusy() {
150
+ this.#busy = false;
151
+ if (this.element.hasAttribute("data-submitting")) {
152
+ this.element.removeAttribute("data-submitting");
153
+ this.element.removeAttribute("aria-busy");
154
+ }
155
+ for (const button of this.#buttons) {
156
+ this.#exitBusy(button);
157
+ }
158
+ }
159
+ /** The triggering button: the event's submitter when ours, else the first button. */
160
+ #resolveSubmitter(event, buttons) {
161
+ const submitter = event.submitter;
162
+ if (submitter && this.#isSubmitButton(submitter) && buttons.includes(submitter)) {
163
+ return submitter;
164
+ }
165
+ return buttons[0] ?? null;
166
+ }
167
+ /** The controlled buttons: `submit` targets, or the form's native submit controls. */
168
+ get #buttons() {
169
+ if (this.hasSubmitTarget) return this.submitTargets;
170
+ return Array.from(
171
+ this.element.querySelectorAll('button[type="submit"], input[type="submit"]')
172
+ );
173
+ }
174
+ #isSubmitButton(el) {
175
+ return el instanceof HTMLButtonElement || el instanceof HTMLInputElement;
176
+ }
177
+ /** Reads a button's visible label (input value, aria-label, or text). */
178
+ #getLabel(button) {
179
+ if (button instanceof HTMLInputElement) return button.value;
180
+ const aria = button.getAttribute("aria-label");
181
+ if (aria !== null) return aria;
182
+ return button.textContent ?? "";
183
+ }
184
+ /** Writes a button's visible label through the same channel it was read from. */
185
+ #setLabel(button, text) {
186
+ if (button instanceof HTMLInputElement) {
187
+ button.value = text;
188
+ return;
189
+ }
190
+ if (button.hasAttribute("aria-label")) {
191
+ button.setAttribute("aria-label", text);
192
+ return;
193
+ }
194
+ button.textContent = text;
195
+ }
196
+ #clearTimeout() {
197
+ if (this.#timeoutId !== null) {
198
+ this.#timeouts.clear(this.#timeoutId);
199
+ this.#timeoutId = null;
200
+ }
201
+ }
202
+ };
203
+
204
+ export { SubmitOnceController };
205
+ //# sourceMappingURL=submit_once_controller.js.map
206
+ //# sourceMappingURL=submit_once_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/submit_once_controller.ts"],"names":[],"mappings":"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","file":"submit_once_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/** Submit-capable controls this controller can disable. */\ntype SubmitButton = HTMLButtonElement | HTMLInputElement;\n\n/** Attribute the original label is parked in while busy (survives a cache restore). */\nconst ORIGINAL_LABEL = \"data-submit-once-original-label\";\n/** Marker on buttons this controller disabled (so we never re-enable authored-disabled ones). */\nconst DISABLED_MARKER = \"data-submit-once-disabled\";\n/** Per-button override for the busy label (a plain attribute, not a Stimulus Value). */\nconst BUTTON_BUSY_LABEL = \"data-submit-once-busy-label\";\n\n/**\n * Headless \"submit once\" behavior: disables the form's submit button(s) on submit\n * to prevent a double submission, optionally swaps in a busy label, and restores\n * on completion. The Headless superset of Rails' `disable_with` (no dedicated APG\n * pattern; follows the WCAG \"status messages\" practice via `aria-busy`).\n *\n * Markup contract (identifier: `stimeo--submit-once`):\n * <form data-controller=\"stimeo--submit-once\"\n * data-stimeo--submit-once-busy-label-value=\"Submitting…\"\n * data-action=\"submit->stimeo--submit-once#start\">\n * <button type=\"submit\" data-stimeo--submit-once-target=\"submit\">Send</button>\n * </form>\n *\n * On submit it disables every `submit` target (or, with none, the form's native\n * `button[type=submit]` / `input[type=submit]`), sets `aria-busy` on them and\n * `data-submitting` on the form, and swaps the **triggering** button's visible\n * label for the busy label (per-button `data-submit-once-busy-label` overrides the\n * form's `busyLabel` Value). It restores on Turbo's `turbo:submit-end`, or after\n * `timeout` ms, re-enabling the buttons and putting the labels back.\n *\n * @remarks\n * Behavior only — no spinner is drawn (pair with {@link \"spinner\"}). The submit-end\n * listener and the timeout are torn down on `disconnect()` (Turbo navigation\n * included), and `connect()` clears any stale busy state parked in a restored cache\n * snapshot so a button is never left disabled. Non-Turbo caveat: the buttons are\n * disabled synchronously inside the `submit` handler, and a plain HTML submission\n * builds its entry list *after* that event — so a disabled submitter's `name`/`value`\n * is excluded from the payload. Turbo is unaffected (it appends the submitter's\n * name/value from attributes when building `FormData`); mirror the value into a\n * hidden field when a non-Turbo form depends on it.\n */\nexport class SubmitOnceController extends Controller<HTMLElement> {\n static override targets = [\"submit\"];\n static override values = {\n busyLabel: { type: String, default: \"\" },\n timeout: { type: Number, default: 0 },\n restoreFocus: { type: Boolean, default: false },\n };\n static actions = [\"start\"] as const;\n static events = [\"start\", \"end\"] as const;\n\n declare readonly submitTargets: SubmitButton[];\n declare readonly hasSubmitTarget: boolean;\n\n declare busyLabelValue: string;\n declare timeoutValue: number;\n declare restoreFocusValue: boolean;\n\n readonly #timeouts = new SafeTimeout();\n #timeoutId: number | null = null;\n #busy = false;\n #submitter: SubmitButton | null = null;\n\n readonly #onSubmitEnd = (event: Event): void => {\n const success = (event as CustomEvent<{ success?: boolean }>).detail?.success;\n this.#restore(success);\n };\n\n override connect(): void {\n this.element.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n // Idempotent: drop any busy state carried over in a restored snapshot so a\n // cached, disabled button does not stay stuck.\n this.#clearStaleBusy();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#clearTimeout();\n }\n\n /** Enters the busy state for the submission started by `event`. */\n start(event: SubmitEvent): void {\n if (this.#busy) return;\n this.#busy = true;\n const buttons = this.#buttons;\n const submitter = this.#resolveSubmitter(event, buttons);\n this.#submitter = submitter;\n for (const button of buttons) {\n this.#enterBusy(button, button === submitter);\n }\n this.element.setAttribute(\"data-submitting\", \"true\");\n this.element.setAttribute(\"aria-busy\", \"true\");\n this.dispatch(\"start\", { detail: {} });\n if (this.timeoutValue > 0) {\n this.#timeoutId = this.#timeouts.set(() => this.#restore(false), this.timeoutValue);\n }\n }\n\n /** Restores the non-busy state, re-enabling buttons and putting labels back. */\n #restore(success?: boolean): void {\n if (!this.#busy) return;\n this.#busy = false;\n this.#clearTimeout();\n for (const button of this.#buttons) {\n this.#exitBusy(button);\n }\n this.element.removeAttribute(\"data-submitting\");\n this.element.removeAttribute(\"aria-busy\");\n this.dispatch(\"end\", { detail: { success } });\n if (this.restoreFocusValue && this.#submitter) {\n this.#submitter.focus();\n }\n this.#submitter = null;\n }\n\n /** Disables a button, marks it busy, and swaps the trigger's label. */\n #enterBusy(button: SubmitButton, isTrigger: boolean): void {\n // Only disable (and mark) buttons that were enabled, so an authored-disabled\n // submit button — e.g. \"disabled until valid\" — is left untouched and is never\n // re-enabled by us on restore.\n if (!button.disabled) {\n button.disabled = true;\n button.setAttribute(DISABLED_MARKER, \"true\");\n button.setAttribute(\"aria-busy\", \"true\");\n }\n if (!isTrigger) return;\n const label = button.getAttribute(BUTTON_BUSY_LABEL) ?? this.busyLabelValue;\n if (label.length === 0) return;\n if (!button.hasAttribute(ORIGINAL_LABEL)) {\n button.setAttribute(ORIGINAL_LABEL, this.#getLabel(button));\n }\n this.#setLabel(button, label);\n }\n\n /** Re-enables a button we disabled and restores its parked label, if any. */\n #exitBusy(button: SubmitButton): void {\n if (button.hasAttribute(DISABLED_MARKER)) {\n button.disabled = false;\n button.removeAttribute(DISABLED_MARKER);\n button.removeAttribute(\"aria-busy\");\n }\n const original = button.getAttribute(ORIGINAL_LABEL);\n if (original !== null) {\n this.#setLabel(button, original);\n button.removeAttribute(ORIGINAL_LABEL);\n }\n }\n\n /** Resets busy state left in a restored cache snapshot without firing events. */\n #clearStaleBusy(): void {\n this.#busy = false;\n if (this.element.hasAttribute(\"data-submitting\")) {\n this.element.removeAttribute(\"data-submitting\");\n this.element.removeAttribute(\"aria-busy\");\n }\n for (const button of this.#buttons) {\n this.#exitBusy(button);\n }\n }\n\n /** The triggering button: the event's submitter when ours, else the first button. */\n #resolveSubmitter(event: SubmitEvent, buttons: SubmitButton[]): SubmitButton | null {\n const submitter = event.submitter;\n if (submitter && this.#isSubmitButton(submitter) && buttons.includes(submitter)) {\n return submitter;\n }\n return buttons[0] ?? null;\n }\n\n /** The controlled buttons: `submit` targets, or the form's native submit controls. */\n get #buttons(): SubmitButton[] {\n if (this.hasSubmitTarget) return this.submitTargets;\n return Array.from(\n this.element.querySelectorAll<SubmitButton>('button[type=\"submit\"], input[type=\"submit\"]'),\n );\n }\n\n #isSubmitButton(el: HTMLElement): el is SubmitButton {\n return el instanceof HTMLButtonElement || el instanceof HTMLInputElement;\n }\n\n /** Reads a button's visible label (input value, aria-label, or text). */\n #getLabel(button: SubmitButton): string {\n if (button instanceof HTMLInputElement) return button.value;\n const aria = button.getAttribute(\"aria-label\");\n if (aria !== null) return aria;\n return button.textContent ?? \"\";\n }\n\n /** Writes a button's visible label through the same channel it was read from. */\n #setLabel(button: SubmitButton, text: string): void {\n if (button instanceof HTMLInputElement) {\n button.value = text;\n return;\n }\n if (button.hasAttribute(\"aria-label\")) {\n button.setAttribute(\"aria-label\", text);\n return;\n }\n button.textContent = text;\n }\n\n #clearTimeout(): void {\n if (this.#timeoutId !== null) {\n this.#timeouts.clear(this.#timeoutId);\n this.#timeoutId = null;\n }\n }\n}\n"]}
@@ -0,0 +1,43 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless, accessible switch (toggle) behavior.
5
+ *
6
+ * Markup contract (identifier: `stimeo--switch`):
7
+ * <button data-controller="stimeo--switch"
8
+ * data-action="stimeo--switch#toggle keydown->stimeo--switch#onKeydown"
9
+ * role="switch" aria-checked="false">…</button>
10
+ *
11
+ * Implements the WAI-ARIA APG **Switch** pattern. The controller element is the
12
+ * switch itself; its on/off state is reflected solely through `aria-checked`.
13
+ *
14
+ * @remarks
15
+ * Behavior only — the consumer owns all styling (typically keyed off the
16
+ * `[aria-checked="true"]` attribute). On a native `<button>` host, the browser
17
+ * already synthesizes a click for Space/Enter, so {@link onKeydown} deliberately
18
+ * does nothing there (to avoid a double toggle) and only drives non-button hosts
19
+ * such as `<div role="switch" tabindex="0">`.
20
+ *
21
+ * Behavior provided:
22
+ * - Click (or Space/Enter) toggles `aria-checked` between `"true"` and `"false"`.
23
+ * - A `stimeo--switch:changed` event is dispatched on every toggle so the
24
+ * consumer can react (its `detail.checked` carries the new boolean state).
25
+ */
26
+ declare class SwitchController extends Controller<HTMLElement> {
27
+ #private;
28
+ static actions: readonly ["onKeydown", "toggle"];
29
+ static events: readonly ["changed"];
30
+ /** Ensures the switch exposes a role and is keyboard-reachable. */
31
+ connect(): void;
32
+ /** Toggles the checked state. Bound via `data-action` (click). */
33
+ toggle(): void;
34
+ /**
35
+ * Activates the switch on Space/Enter for non-native hosts and prevents the
36
+ * default Space scroll. Bound via `data-action` (keydown). Native `<button>`
37
+ * hosts are skipped because the browser already turns Space/Enter into a click,
38
+ * which would otherwise toggle the switch twice.
39
+ */
40
+ onKeydown(event: KeyboardEvent): void;
41
+ }
42
+
43
+ export { SwitchController };
@@ -0,0 +1,50 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/switch_controller.ts
4
+ var SwitchController = class extends Controller {
5
+ static actions = ["onKeydown", "toggle"];
6
+ static events = ["changed"];
7
+ /** Ensures the switch exposes a role and is keyboard-reachable. */
8
+ connect() {
9
+ if (!this.element.hasAttribute("role")) {
10
+ this.element.setAttribute("role", "switch");
11
+ }
12
+ if (!this.element.hasAttribute("aria-checked")) {
13
+ this.element.setAttribute("aria-checked", "false");
14
+ }
15
+ if (!(this.element instanceof HTMLButtonElement) && !this.element.hasAttribute("tabindex")) {
16
+ this.element.setAttribute("tabindex", "0");
17
+ }
18
+ }
19
+ /** Toggles the checked state. Bound via `data-action` (click). */
20
+ toggle() {
21
+ this.#checked = !this.#checked;
22
+ }
23
+ /**
24
+ * Activates the switch on Space/Enter for non-native hosts and prevents the
25
+ * default Space scroll. Bound via `data-action` (keydown). Native `<button>`
26
+ * hosts are skipped because the browser already turns Space/Enter into a click,
27
+ * which would otherwise toggle the switch twice.
28
+ */
29
+ onKeydown(event) {
30
+ if (this.element instanceof HTMLButtonElement) return;
31
+ if (event.repeat) return;
32
+ if (event.key === " " || event.key === "Enter") {
33
+ event.preventDefault();
34
+ this.toggle();
35
+ }
36
+ }
37
+ /** Whether the switch is currently on. */
38
+ get #checked() {
39
+ return this.element.getAttribute("aria-checked") === "true";
40
+ }
41
+ /** Reflects the new state on `aria-checked` and notifies listeners. */
42
+ set #checked(value) {
43
+ this.element.setAttribute("aria-checked", value ? "true" : "false");
44
+ this.dispatch("changed", { detail: { checked: value } });
45
+ }
46
+ };
47
+
48
+ export { SwitchController };
49
+ //# sourceMappingURL=switch_controller.js.map
50
+ //# sourceMappingURL=switch_controller.js.map