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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stimeo Labs (taiyaky)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Stimeo UI
2
+
3
+ **Headless Stimulus UI framework for Ruby on Rails.** Stimeo UI ships *behavior*
4
+ — ARIA state, keyboard interaction, focus management, Turbo resilience — as
5
+ `data-*`-driven Stimulus controllers. It does **not** ship CSS: the consuming app
6
+ owns the look entirely.
7
+
8
+ - Lean by design: the **core** needs only `@hotwired/stimulus` at runtime (kept
9
+ external in the build). The opt-in `stimeo-ui/positioning` module is the one
10
+ exception — it uses `@floating-ui/dom` (an optional **peer dependency**; see the
11
+ Peer dependencies note below).
12
+ - Accessibility first: every controller follows the relevant WAI-ARIA APG pattern
13
+ and the related WCAG 2.2 AA criteria.
14
+ - Public controller identifiers use the `stimeo--` namespace (e.g.
15
+ `stimeo--dropdown`).
16
+
17
+ > Status: **alpha** (`0.x`). The `stimeo--*` attribute API may still change before
18
+ > 1.0 — pin your version.
19
+
20
+ ## Install
21
+
22
+ ### Rails with importmap (recommended)
23
+
24
+ ```bash
25
+ bundle add stimeo-ui
26
+ bin/rails generate stimeo:install
27
+ ```
28
+
29
+ The generator vendors the prebuilt JS into `vendor/javascript/stimeo/`, pins
30
+ `stimeo-ui` in `config/importmap.rb`, and registers all controllers with your
31
+ Stimulus application. Then drive components from HTML alone:
32
+
33
+ ```erb
34
+ <div data-controller="stimeo--dropdown">
35
+ <button data-stimeo--dropdown-target="trigger"
36
+ data-action="click->stimeo--dropdown#toggle">Menu</button>
37
+ <div data-stimeo--dropdown-target="menu" hidden>…</div>
38
+ </div>
39
+ ```
40
+
41
+ ### npm (jsbundling or any bundler)
42
+
43
+ ```bash
44
+ npm install stimeo-ui @hotwired/stimulus
45
+ ```
46
+
47
+ ```js
48
+ import { Application } from "@hotwired/stimulus";
49
+ import { registerStimeo } from "stimeo-ui";
50
+
51
+ const application = Application.start();
52
+ registerStimeo(application); // registers every stimeo--* controller
53
+ ```
54
+
55
+ Need only a few controllers? Import them individually from
56
+ `stimeo-ui/controllers/*` and register them under your own identifiers.
57
+
58
+ - **Peer dependencies:** `@hotwired/stimulus` (always), `@floating-ui/dom` (only
59
+ if you use the opt-in `stimeo-ui/positioning` module — tooltips, popovers, etc.
60
+ work without it via the default flow layout).
61
+ - **No CSS is shipped.** Style the components yourself; controllers only toggle
62
+ ARIA state and `data-*` hooks.
63
+
64
+ ## Contributing
65
+
66
+ Bug reports and feature requests are very welcome — please open a GitHub issue.
67
+ For code changes, open an issue first to discuss direction; see
68
+ [`CONTRIBUTING.md`](CONTRIBUTING.md).
69
+
70
+ ## License
71
+
72
+ Released under the [MIT License](LICENSE) © Stimeo Labs.
@@ -0,0 +1,59 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless, accessible accordion behavior.
5
+ *
6
+ * Markup contract (identifier: `stimeo--accordion`):
7
+ * <div data-controller="stimeo--accordion">
8
+ * <h3>
9
+ * <button data-stimeo--accordion-target="trigger"
10
+ * data-action="stimeo--accordion#toggle
11
+ * keydown->stimeo--accordion#onKeydown"
12
+ * aria-expanded="false" aria-controls="panel-1">Section 1</button>
13
+ * </h3>
14
+ * <div id="panel-1" data-stimeo--accordion-target="panel"
15
+ * role="region" hidden>…</div>
16
+ * <!-- repeat header/panel pairs -->
17
+ * </div>
18
+ *
19
+ * Implements the WAI-ARIA APG **Accordion** pattern. Each header button is
20
+ * associated with its panel through `aria-controls`; the panel's `id` is the
21
+ * single source of truth for the pairing, so headers and panels need not be
22
+ * adjacent siblings.
23
+ *
24
+ * @remarks
25
+ * Multiple panels may be open at once (this is the APG-allowed default). State
26
+ * is reflected through `aria-expanded` on the header and the `hidden` attribute
27
+ * on the panel — never through visual styling, which the consumer owns.
28
+ *
29
+ * Behavior provided:
30
+ * - Click a header to toggle its panel (`aria-expanded` + `hidden` reflect state).
31
+ * - `ArrowDown`/`ArrowUp` move focus between headers; `Home`/`End` jump to the
32
+ * first/last header. Hidden headers (including those inside a hidden subtree,
33
+ * e.g. a section a filter collapsed) are skipped so focus stays perceivable.
34
+ * - {@link expandAll} / {@link collapseAll} open or close every panel at once,
35
+ * for an optional "expand all / collapse all" control pair anywhere in scope.
36
+ */
37
+ declare class AccordionController extends Controller<HTMLElement> {
38
+ #private;
39
+ static targets: string[];
40
+ static actions: readonly ["collapseAll", "expandAll", "onKeydown", "toggle"];
41
+ readonly triggerTargets: HTMLButtonElement[];
42
+ readonly panelTargets: HTMLElement[];
43
+ /** Toggles the panel controlled by the activated header. */
44
+ toggle(event: Event): void;
45
+ /** Opens every panel. Bound via `data-action` on an "expand all" control. */
46
+ expandAll(): void;
47
+ /** Closes every panel. Bound via `data-action` on a "collapse all" control. */
48
+ collapseAll(): void;
49
+ /**
50
+ * Moves focus between headers per the APG keyboard model, skipping any header
51
+ * that is hidden or nested in a hidden subtree. A consumer may hide whole
52
+ * sections (e.g. an `stimeo--filter` that collapses empty groups), and an
53
+ * unperceivable header must never become an arrow-key target — otherwise
54
+ * `.focus()` lands on nothing and navigation appears to stall.
55
+ */
56
+ onKeydown(event: KeyboardEvent): void;
57
+ }
58
+
59
+ export { AccordionController };
@@ -0,0 +1,76 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/accordion_controller.ts
4
+ var AccordionController = class extends Controller {
5
+ static targets = ["trigger", "panel"];
6
+ static actions = ["collapseAll", "expandAll", "onKeydown", "toggle"];
7
+ /** Toggles the panel controlled by the activated header. */
8
+ toggle(event) {
9
+ const trigger = event.currentTarget;
10
+ const panel = this.#panelFor(trigger);
11
+ if (!panel) return;
12
+ this.#setExpanded(trigger, panel, trigger.getAttribute("aria-expanded") !== "true");
13
+ }
14
+ /** Opens every panel. Bound via `data-action` on an "expand all" control. */
15
+ expandAll() {
16
+ this.#setAll(true);
17
+ }
18
+ /** Closes every panel. Bound via `data-action` on a "collapse all" control. */
19
+ collapseAll() {
20
+ this.#setAll(false);
21
+ }
22
+ /** Drives every header/panel pair to the same expanded state. */
23
+ #setAll(open) {
24
+ for (const trigger of this.triggerTargets) {
25
+ const panel = this.#panelFor(trigger);
26
+ if (panel) this.#setExpanded(trigger, panel, open);
27
+ }
28
+ }
29
+ /** Reflects one header/panel pair's state through `aria-expanded` + `hidden`. */
30
+ #setExpanded(trigger, panel, open) {
31
+ trigger.setAttribute("aria-expanded", open ? "true" : "false");
32
+ panel.hidden = !open;
33
+ }
34
+ /**
35
+ * Moves focus between headers per the APG keyboard model, skipping any header
36
+ * that is hidden or nested in a hidden subtree. A consumer may hide whole
37
+ * sections (e.g. an `stimeo--filter` that collapses empty groups), and an
38
+ * unperceivable header must never become an arrow-key target — otherwise
39
+ * `.focus()` lands on nothing and navigation appears to stall.
40
+ */
41
+ onKeydown(event) {
42
+ const current = event.currentTarget;
43
+ if (this.triggerTargets.indexOf(current) === -1) return;
44
+ const navigable = this.triggerTargets.filter((trigger) => trigger.closest("[hidden]") === null);
45
+ const here = navigable.indexOf(current);
46
+ if (here === -1) return;
47
+ let next;
48
+ switch (event.key) {
49
+ case "ArrowDown":
50
+ next = navigable[(here + 1) % navigable.length];
51
+ break;
52
+ case "ArrowUp":
53
+ next = navigable[(here - 1 + navigable.length) % navigable.length];
54
+ break;
55
+ case "Home":
56
+ next = navigable[0];
57
+ break;
58
+ case "End":
59
+ next = navigable[navigable.length - 1];
60
+ break;
61
+ default:
62
+ return;
63
+ }
64
+ event.preventDefault();
65
+ next?.focus();
66
+ }
67
+ /** Resolves the panel a header controls via its `aria-controls` reference. */
68
+ #panelFor(trigger) {
69
+ const id = trigger.getAttribute("aria-controls");
70
+ return id ? this.panelTargets.find((panel) => panel.id === id) ?? null : null;
71
+ }
72
+ };
73
+
74
+ export { AccordionController };
75
+ //# sourceMappingURL=accordion_controller.js.map
76
+ //# sourceMappingURL=accordion_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/accordion_controller.ts"],"names":[],"mappings":"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","file":"accordion_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible accordion behavior.\n *\n * Markup contract (identifier: `stimeo--accordion`):\n * <div data-controller=\"stimeo--accordion\">\n * <h3>\n * <button data-stimeo--accordion-target=\"trigger\"\n * data-action=\"stimeo--accordion#toggle\n * keydown->stimeo--accordion#onKeydown\"\n * aria-expanded=\"false\" aria-controls=\"panel-1\">Section 1</button>\n * </h3>\n * <div id=\"panel-1\" data-stimeo--accordion-target=\"panel\"\n * role=\"region\" hidden>…</div>\n * <!-- repeat header/panel pairs -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Accordion** pattern. Each header button is\n * associated with its panel through `aria-controls`; the panel's `id` is the\n * single source of truth for the pairing, so headers and panels need not be\n * adjacent siblings.\n *\n * @remarks\n * Multiple panels may be open at once (this is the APG-allowed default). State\n * is reflected through `aria-expanded` on the header and the `hidden` attribute\n * on the panel — never through visual styling, which the consumer owns.\n *\n * Behavior provided:\n * - Click a header to toggle its panel (`aria-expanded` + `hidden` reflect state).\n * - `ArrowDown`/`ArrowUp` move focus between headers; `Home`/`End` jump to the\n * first/last header. Hidden headers (including those inside a hidden subtree,\n * e.g. a section a filter collapsed) are skipped so focus stays perceivable.\n * - {@link expandAll} / {@link collapseAll} open or close every panel at once,\n * for an optional \"expand all / collapse all\" control pair anywhere in scope.\n */\nexport class AccordionController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"panel\"];\n static actions = [\"collapseAll\", \"expandAll\", \"onKeydown\", \"toggle\"] as const;\n\n declare readonly triggerTargets: HTMLButtonElement[];\n declare readonly panelTargets: HTMLElement[];\n\n /** Toggles the panel controlled by the activated header. */\n toggle(event: Event): void {\n const trigger = event.currentTarget as HTMLButtonElement;\n const panel = this.#panelFor(trigger);\n if (!panel) return;\n\n this.#setExpanded(trigger, panel, trigger.getAttribute(\"aria-expanded\") !== \"true\");\n }\n\n /** Opens every panel. Bound via `data-action` on an \"expand all\" control. */\n expandAll(): void {\n this.#setAll(true);\n }\n\n /** Closes every panel. Bound via `data-action` on a \"collapse all\" control. */\n collapseAll(): void {\n this.#setAll(false);\n }\n\n /** Drives every header/panel pair to the same expanded state. */\n #setAll(open: boolean): void {\n for (const trigger of this.triggerTargets) {\n const panel = this.#panelFor(trigger);\n if (panel) this.#setExpanded(trigger, panel, open);\n }\n }\n\n /** Reflects one header/panel pair's state through `aria-expanded` + `hidden`. */\n #setExpanded(trigger: HTMLButtonElement, panel: HTMLElement, open: boolean): void {\n trigger.setAttribute(\"aria-expanded\", open ? \"true\" : \"false\");\n panel.hidden = !open;\n }\n\n /**\n * Moves focus between headers per the APG keyboard model, skipping any header\n * that is hidden or nested in a hidden subtree. A consumer may hide whole\n * sections (e.g. an `stimeo--filter` that collapses empty groups), and an\n * unperceivable header must never become an arrow-key target — otherwise\n * `.focus()` lands on nothing and navigation appears to stall.\n */\n onKeydown(event: KeyboardEvent): void {\n const current = event.currentTarget as HTMLButtonElement;\n if (this.triggerTargets.indexOf(current) === -1) return;\n\n // `closest(\"[hidden]\")` catches both a directly-hidden header and one inside a\n // hidden ancestor (the filter-group case); navigate over the visible set only.\n const navigable = this.triggerTargets.filter((trigger) => trigger.closest(\"[hidden]\") === null);\n const here = navigable.indexOf(current);\n if (here === -1) return;\n\n let next: HTMLButtonElement | undefined;\n switch (event.key) {\n case \"ArrowDown\":\n next = navigable[(here + 1) % navigable.length];\n break;\n case \"ArrowUp\":\n next = navigable[(here - 1 + navigable.length) % navigable.length];\n break;\n case \"Home\":\n next = navigable[0];\n break;\n case \"End\":\n next = navigable[navigable.length - 1];\n break;\n default:\n return;\n }\n\n event.preventDefault();\n next?.focus();\n }\n\n /** Resolves the panel a header controls via its `aria-controls` reference. */\n #panelFor(trigger: HTMLButtonElement): HTMLElement | null {\n const id = trigger.getAttribute(\"aria-controls\");\n return id ? (this.panelTargets.find((panel) => panel.id === id) ?? null) : null;\n }\n}\n"]}
@@ -0,0 +1,78 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless, shared **live-region announcer** — a polite/assertive screen-reader
5
+ * announcement base (no dedicated APG pattern; follows the WAI-ARIA "Alert" /
6
+ * "Status" live-region guidance and WCAG 2.2 **4.1.3 Status Messages**).
7
+ *
8
+ * Markup contract (identifier: `stimeo--announcer`):
9
+ * <!-- Place once per page; the consumer visually hides the regions in CSS. -->
10
+ * <div data-controller="stimeo--announcer">
11
+ * <div data-stimeo--announcer-target="polite" aria-live="polite" aria-atomic="true"></div>
12
+ * <div data-stimeo--announcer-target="assertive" aria-live="assertive" aria-atomic="true"></div>
13
+ * </div>
14
+ *
15
+ * <!-- Attribute-only trigger: declare the activation event explicitly. -->
16
+ * <button data-action="click->stimeo--announcer#announce"
17
+ * data-stimeo--announcer-message-param="Saved"
18
+ * data-stimeo--announcer-assertive-param="false">Save</button>
19
+ *
20
+ * <!-- Programmatic trigger (e.g. from another controller / Turbo Stream). -->
21
+ * window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", {
22
+ * detail: { message: "12 results", assertive: false },
23
+ * }))
24
+ *
25
+ * The announcer is the shared substrate other controllers (Auto-Submit, Flash,
26
+ * Bulk Select, …) lean on instead of each carrying their own live region.
27
+ *
28
+ * @remarks
29
+ * Behavior only, with **one deliberate exception**: when a `polite`/`assertive`
30
+ * target is absent the controller *generates* the missing region and applies the
31
+ * canonical visually-hidden inline style (see {@link visuallyHide}). A live region
32
+ * must exist and be visually hidden to do its job, and a generated node has no
33
+ * consumer CSS hook to hide it; consumers who want to own styling supply their own
34
+ * targets. The controller never moves focus — announcements must not steal it
35
+ * (WCAG 2.2 4.1.3). Listeners and clear timers are torn down on `disconnect()`
36
+ * (Turbo included), and any generated regions are removed.
37
+ */
38
+ declare class AnnouncerController extends Controller<HTMLElement> {
39
+ #private;
40
+ static targets: string[];
41
+ static values: {
42
+ clearAfter: {
43
+ type: NumberConstructor;
44
+ default: number;
45
+ };
46
+ dedupeReannounce: {
47
+ type: BooleanConstructor;
48
+ default: boolean;
49
+ };
50
+ };
51
+ static actions: readonly ["announce"];
52
+ readonly politeTarget: HTMLElement;
53
+ readonly assertiveTarget: HTMLElement;
54
+ readonly hasPoliteTarget: boolean;
55
+ readonly hasAssertiveTarget: boolean;
56
+ clearAfterValue: number;
57
+ dedupeReannounceValue: boolean;
58
+ connect(): void;
59
+ disconnect(): void;
60
+ /**
61
+ * Announces a message. Reads the text from a Stimulus action param
62
+ * (`message`, plus optional `assertive`) for attribute-only triggers, falling
63
+ * back to a CustomEvent `detail` when the same handler is wired to an event.
64
+ * An empty/non-string message is ignored so untrusted payloads cannot blank
65
+ * the region.
66
+ */
67
+ announce(event: Event): void;
68
+ }
69
+ /**
70
+ * Applies the canonical visually-hidden ("sr-only") inline style to a generated
71
+ * live region so its text is announced without being seen. Inline so the library
72
+ * stays self-contained when the consumer provides no target/CSS of its own.
73
+ *
74
+ * Pure (no `this`); exported for direct unit testing.
75
+ */
76
+ declare function visuallyHide(node: HTMLElement): void;
77
+
78
+ export { AnnouncerController, visuallyHide };
@@ -0,0 +1,184 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/announcer_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/announcer_controller.ts
59
+ var AnnouncerController = class extends Controller {
60
+ static targets = ["polite", "assertive"];
61
+ static values = {
62
+ clearAfter: { type: Number, default: 1e3 },
63
+ dedupeReannounce: { type: Boolean, default: true }
64
+ };
65
+ static actions = ["announce"];
66
+ /** Clear/re-announce timers; one `clearAll()` in disconnect tears them all down. */
67
+ #timers = new SafeTimeout();
68
+ /** Live regions generated to stand in for absent targets, for teardown. */
69
+ #generated = /* @__PURE__ */ new Map();
70
+ /**
71
+ * Guards against handling the same CustomEvent twice. An event dispatched on
72
+ * the controller element with `bubbles: true` reaches both the element and the
73
+ * `window` listener; this WeakSet ensures it announces only once.
74
+ */
75
+ #handled = /* @__PURE__ */ new WeakSet();
76
+ /** Receives programmatic announcements at the element or bubbled to `window`. */
77
+ #onAnnounceEvent = (event) => {
78
+ if (this.#handled.has(event)) return;
79
+ this.#handled.add(event);
80
+ const detail = event.detail;
81
+ const message = this.#messageFromDetail(detail);
82
+ if (!message) return;
83
+ this.#announce(message, this.#assertiveFromDetail(detail));
84
+ };
85
+ connect() {
86
+ this.element.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
87
+ window.addEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
88
+ }
89
+ disconnect() {
90
+ this.element.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
91
+ window.removeEventListener("stimeo--announcer:announce", this.#onAnnounceEvent);
92
+ this.#timers.clearAll();
93
+ for (const region of this.#generated.values()) {
94
+ region.remove();
95
+ }
96
+ this.#generated.clear();
97
+ }
98
+ /**
99
+ * Announces a message. Reads the text from a Stimulus action param
100
+ * (`message`, plus optional `assertive`) for attribute-only triggers, falling
101
+ * back to a CustomEvent `detail` when the same handler is wired to an event.
102
+ * An empty/non-string message is ignored so untrusted payloads cannot blank
103
+ * the region.
104
+ */
105
+ announce(event) {
106
+ const params = event.params;
107
+ const fromParam = params?.message;
108
+ const message = typeof fromParam === "string" && fromParam.length > 0 ? fromParam : this.#messageFromDetail(event.detail);
109
+ if (!message) return;
110
+ const assertive = params?.assertive === true || this.#assertiveFromDetail(event.detail);
111
+ this.#announce(message, assertive);
112
+ }
113
+ /**
114
+ * Writes `message` into the matching live region and schedules its clear.
115
+ *
116
+ * When the region already holds the same text, an aria-atomic region is not
117
+ * re-read by assistive tech (the node did not change). If `dedupeReannounce`
118
+ * is on, the text is cleared and re-set on a later task so the mutation is
119
+ * observed and announced again.
120
+ */
121
+ #announce(message, assertive) {
122
+ const region = this.#regionFor(assertive ? "assertive" : "polite");
123
+ if (this.dedupeReannounceValue && region.textContent === message) {
124
+ region.textContent = "";
125
+ this.#timers.set(() => {
126
+ region.textContent = message;
127
+ this.#scheduleClear(region, message);
128
+ }, 0);
129
+ return;
130
+ }
131
+ region.textContent = message;
132
+ this.#scheduleClear(region, message);
133
+ }
134
+ /** Clears the region after `clearAfter` ms, unless a newer message replaced it. */
135
+ #scheduleClear(region, message) {
136
+ if (this.clearAfterValue <= 0) return;
137
+ this.#timers.set(() => {
138
+ if (region.textContent === message) region.textContent = "";
139
+ }, this.clearAfterValue);
140
+ }
141
+ /** Resolves the live region for a politeness level, generating it if absent. */
142
+ #regionFor(level) {
143
+ if (level === "assertive" && this.hasAssertiveTarget) return this.assertiveTarget;
144
+ if (level === "polite" && this.hasPoliteTarget) return this.politeTarget;
145
+ const existing = this.#generated.get(level);
146
+ if (existing) return existing;
147
+ const region = document.createElement("div");
148
+ region.setAttribute("aria-live", level);
149
+ region.setAttribute("aria-atomic", "true");
150
+ visuallyHide(region);
151
+ this.element.appendChild(region);
152
+ this.#generated.set(level, region);
153
+ return region;
154
+ }
155
+ /** Extracts a non-empty string `message` from a CustomEvent detail, else null. */
156
+ #messageFromDetail(detail) {
157
+ if (detail && typeof detail === "object" && "message" in detail) {
158
+ const value = detail.message;
159
+ if (typeof value === "string" && value.length > 0) return value;
160
+ }
161
+ return null;
162
+ }
163
+ /** Reads an `assertive === true` flag from a CustomEvent detail (default polite). */
164
+ #assertiveFromDetail(detail) {
165
+ return !!detail && typeof detail === "object" && detail.assertive === true;
166
+ }
167
+ };
168
+ function visuallyHide(node) {
169
+ const { style } = node;
170
+ style.position = "absolute";
171
+ style.width = "1px";
172
+ style.height = "1px";
173
+ style.margin = "-1px";
174
+ style.padding = "0";
175
+ style.border = "0";
176
+ style.overflow = "hidden";
177
+ style.clip = "rect(0 0 0 0)";
178
+ style.clipPath = "inset(50%)";
179
+ style.whiteSpace = "nowrap";
180
+ }
181
+
182
+ export { AnnouncerController, visuallyHide };
183
+ //# sourceMappingURL=announcer_controller.js.map
184
+ //# sourceMappingURL=announcer_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/announcer_controller.ts"],"names":[],"mappings":"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","file":"announcer_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, shared **live-region announcer** — a polite/assertive screen-reader\n * announcement base (no dedicated APG pattern; follows the WAI-ARIA \"Alert\" /\n * \"Status\" live-region guidance and WCAG 2.2 **4.1.3 Status Messages**).\n *\n * Markup contract (identifier: `stimeo--announcer`):\n * <!-- Place once per page; the consumer visually hides the regions in CSS. -->\n * <div data-controller=\"stimeo--announcer\">\n * <div data-stimeo--announcer-target=\"polite\" aria-live=\"polite\" aria-atomic=\"true\"></div>\n * <div data-stimeo--announcer-target=\"assertive\" aria-live=\"assertive\" aria-atomic=\"true\"></div>\n * </div>\n *\n * <!-- Attribute-only trigger: declare the activation event explicitly. -->\n * <button data-action=\"click->stimeo--announcer#announce\"\n * data-stimeo--announcer-message-param=\"Saved\"\n * data-stimeo--announcer-assertive-param=\"false\">Save</button>\n *\n * <!-- Programmatic trigger (e.g. from another controller / Turbo Stream). -->\n * window.dispatchEvent(new CustomEvent(\"stimeo--announcer:announce\", {\n * detail: { message: \"12 results\", assertive: false },\n * }))\n *\n * The announcer is the shared substrate other controllers (Auto-Submit, Flash,\n * Bulk Select, …) lean on instead of each carrying their own live region.\n *\n * @remarks\n * Behavior only, with **one deliberate exception**: when a `polite`/`assertive`\n * target is absent the controller *generates* the missing region and applies the\n * canonical visually-hidden inline style (see {@link visuallyHide}). A live region\n * must exist and be visually hidden to do its job, and a generated node has no\n * consumer CSS hook to hide it; consumers who want to own styling supply their own\n * targets. The controller never moves focus — announcements must not steal it\n * (WCAG 2.2 4.1.3). Listeners and clear timers are torn down on `disconnect()`\n * (Turbo included), and any generated regions are removed.\n */\nexport class AnnouncerController extends Controller<HTMLElement> {\n static override targets = [\"polite\", \"assertive\"];\n static override values = {\n clearAfter: { type: Number, default: 1000 },\n dedupeReannounce: { type: Boolean, default: true },\n };\n static actions = [\"announce\"] as const;\n\n declare readonly politeTarget: HTMLElement;\n declare readonly assertiveTarget: HTMLElement;\n declare readonly hasPoliteTarget: boolean;\n declare readonly hasAssertiveTarget: boolean;\n\n declare clearAfterValue: number;\n declare dedupeReannounceValue: boolean;\n\n /** Clear/re-announce timers; one `clearAll()` in disconnect tears them all down. */\n readonly #timers = new SafeTimeout();\n\n /** Live regions generated to stand in for absent targets, for teardown. */\n readonly #generated = new Map<\"assertive\" | \"polite\", HTMLElement>();\n\n /**\n * Guards against handling the same CustomEvent twice. An event dispatched on\n * the controller element with `bubbles: true` reaches both the element and the\n * `window` listener; this WeakSet ensures it announces only once.\n */\n readonly #handled = new WeakSet<Event>();\n\n /** Receives programmatic announcements at the element or bubbled to `window`. */\n readonly #onAnnounceEvent = (event: Event): void => {\n if (this.#handled.has(event)) return;\n this.#handled.add(event);\n const detail = (event as CustomEvent<unknown>).detail;\n const message = this.#messageFromDetail(detail);\n if (!message) return;\n this.#announce(message, this.#assertiveFromDetail(detail));\n };\n\n override connect(): void {\n this.element.addEventListener(\"stimeo--announcer:announce\", this.#onAnnounceEvent);\n window.addEventListener(\"stimeo--announcer:announce\", this.#onAnnounceEvent);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"stimeo--announcer:announce\", this.#onAnnounceEvent);\n window.removeEventListener(\"stimeo--announcer:announce\", this.#onAnnounceEvent);\n this.#timers.clearAll();\n for (const region of this.#generated.values()) {\n region.remove();\n }\n this.#generated.clear();\n }\n\n /**\n * Announces a message. Reads the text from a Stimulus action param\n * (`message`, plus optional `assertive`) for attribute-only triggers, falling\n * back to a CustomEvent `detail` when the same handler is wired to an event.\n * An empty/non-string message is ignored so untrusted payloads cannot blank\n * the region.\n */\n announce(event: Event): void {\n const params = (event as { params?: Record<string, unknown> }).params;\n const fromParam = params?.message;\n const message =\n typeof fromParam === \"string\" && fromParam.length > 0\n ? fromParam\n : this.#messageFromDetail((event as CustomEvent<unknown>).detail);\n if (!message) return;\n\n const assertive =\n params?.assertive === true ||\n this.#assertiveFromDetail((event as CustomEvent<unknown>).detail);\n this.#announce(message, assertive);\n }\n\n /**\n * Writes `message` into the matching live region and schedules its clear.\n *\n * When the region already holds the same text, an aria-atomic region is not\n * re-read by assistive tech (the node did not change). If `dedupeReannounce`\n * is on, the text is cleared and re-set on a later task so the mutation is\n * observed and announced again.\n */\n #announce(message: string, assertive: boolean): void {\n const region = this.#regionFor(assertive ? \"assertive\" : \"polite\");\n\n if (this.dedupeReannounceValue && region.textContent === message) {\n region.textContent = \"\";\n this.#timers.set(() => {\n region.textContent = message;\n this.#scheduleClear(region, message);\n }, 0);\n return;\n }\n\n region.textContent = message;\n this.#scheduleClear(region, message);\n }\n\n /** Clears the region after `clearAfter` ms, unless a newer message replaced it. */\n #scheduleClear(region: HTMLElement, message: string): void {\n if (this.clearAfterValue <= 0) return;\n this.#timers.set(() => {\n if (region.textContent === message) region.textContent = \"\";\n }, this.clearAfterValue);\n }\n\n /** Resolves the live region for a politeness level, generating it if absent. */\n #regionFor(level: \"assertive\" | \"polite\"): HTMLElement {\n if (level === \"assertive\" && this.hasAssertiveTarget) return this.assertiveTarget;\n if (level === \"polite\" && this.hasPoliteTarget) return this.politeTarget;\n\n const existing = this.#generated.get(level);\n if (existing) return existing;\n\n const region = document.createElement(\"div\");\n region.setAttribute(\"aria-live\", level);\n region.setAttribute(\"aria-atomic\", \"true\");\n visuallyHide(region);\n this.element.appendChild(region);\n this.#generated.set(level, region);\n return region;\n }\n\n /** Extracts a non-empty string `message` from a CustomEvent detail, else null. */\n #messageFromDetail(detail: unknown): string | null {\n if (detail && typeof detail === \"object\" && \"message\" in detail) {\n const value = (detail as Record<string, unknown>).message;\n if (typeof value === \"string\" && value.length > 0) return value;\n }\n return null;\n }\n\n /** Reads an `assertive === true` flag from a CustomEvent detail (default polite). */\n #assertiveFromDetail(detail: unknown): boolean {\n return (\n !!detail &&\n typeof detail === \"object\" &&\n (detail as Record<string, unknown>).assertive === true\n );\n }\n}\n\n/**\n * Applies the canonical visually-hidden (\"sr-only\") inline style to a generated\n * live region so its text is announced without being seen. Inline so the library\n * stays self-contained when the consumer provides no target/CSS of its own.\n *\n * Pure (no `this`); exported for direct unit testing.\n */\nexport function visuallyHide(node: HTMLElement): void {\n const { style } = node;\n style.position = \"absolute\";\n style.width = \"1px\";\n style.height = \"1px\";\n style.margin = \"-1px\";\n style.padding = \"0\";\n style.border = \"0\";\n style.overflow = \"hidden\";\n style.clip = \"rect(0 0 0 0)\";\n style.clipPath = \"inset(50%)\";\n style.whiteSpace = \"nowrap\";\n}\n"]}
@@ -0,0 +1,39 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless **Aspect Ratio** helper. No APG pattern — a pure layout utility with
5
+ * no role or state.
6
+ *
7
+ * Markup contract (identifier: `stimeo--aspect-ratio`):
8
+ * <div data-controller="stimeo--aspect-ratio"
9
+ * data-stimeo--aspect-ratio-ratio-value="16/9">
10
+ * <img src="/cover.jpg" alt="Cover"
11
+ * data-stimeo--aspect-ratio-target="content" />
12
+ * </div>
13
+ *
14
+ * Supplies the requested ratio as the `--stimeo-aspect-ratio` custom property on
15
+ * the host so consumer CSS can drive the box (`aspect-ratio: var(--stimeo-aspect-ratio)`)
16
+ * or a padding-hack fallback. The drawing itself — `aspect-ratio`, `object-fit`,
17
+ * cropping — stays in the consumer's stylesheet.
18
+ *
19
+ * @remarks
20
+ * Behavior only. The value accepts the CSS `<ratio>` forms `"16/9"` and a bare
21
+ * number string (`"1.5"`); it is normalized to `"w / h"` (or the number) and an
22
+ * unparseable value falls back to the default `1 / 1` rather than writing garbage
23
+ * into the custom property. The reflection re-runs when the value changes.
24
+ */
25
+ declare class AspectRatioController extends Controller<HTMLElement> {
26
+ #private;
27
+ static targets: string[];
28
+ static values: {
29
+ ratio: {
30
+ type: StringConstructor;
31
+ default: string;
32
+ };
33
+ };
34
+ ratioValue: string;
35
+ /** Applies the ratio on connect and whenever the value changes. */
36
+ ratioValueChanged(): void;
37
+ }
38
+
39
+ export { AspectRatioController };
@@ -0,0 +1,36 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/aspect_ratio_controller.ts
4
+ var AspectRatioController = class extends Controller {
5
+ static targets = ["content"];
6
+ static values = {
7
+ ratio: { type: String, default: "1/1" }
8
+ };
9
+ /** Applies the ratio on connect and whenever the value changes. */
10
+ ratioValueChanged() {
11
+ this.element.style.setProperty("--stimeo-aspect-ratio", this.#normalizeRatio(this.ratioValue));
12
+ }
13
+ /**
14
+ * Normalizes a ratio string to a valid CSS `<ratio>`:
15
+ * - `"16/9"` / `"16 / 9"` → `"16 / 9"` (both parts must be positive numbers)
16
+ * - `"1.5"` → `"1.5"` (a bare positive number)
17
+ * - anything else → `"1 / 1"` (the default), so the custom property is always valid.
18
+ */
19
+ #normalizeRatio(raw) {
20
+ const value = raw.trim();
21
+ if (value.includes("/")) {
22
+ const [w, h] = value.split("/").map((part) => Number.parseFloat(part.trim()));
23
+ if (this.#isPositive(w) && this.#isPositive(h)) return `${w} / ${h}`;
24
+ return "1 / 1";
25
+ }
26
+ const single = Number.parseFloat(value);
27
+ return this.#isPositive(single) ? String(single) : "1 / 1";
28
+ }
29
+ #isPositive(value) {
30
+ return value !== void 0 && Number.isFinite(value) && value > 0;
31
+ }
32
+ };
33
+
34
+ export { AspectRatioController };
35
+ //# sourceMappingURL=aspect_ratio_controller.js.map
36
+ //# sourceMappingURL=aspect_ratio_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/aspect_ratio_controller.ts"],"names":[],"mappings":";;;AAwBO,IAAM,qBAAA,GAAN,cAAoC,UAAA,CAAwB;AAAA,EACjE,OAAgB,OAAA,GAAU,CAAC,SAAS,CAAA;AAAA,EACpC,OAAgB,MAAA,GAAS;AAAA,IACvB,KAAA,EAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,KAAA;AAAM,GACxC;AAAA;AAAA,EAKA,iBAAA,GAA0B;AACxB,IAAA,IAAA,CAAK,OAAA,CAAQ,MAAM,WAAA,CAAY,uBAAA,EAAyB,KAAK,eAAA,CAAgB,IAAA,CAAK,UAAU,CAAC,CAAA;AAAA,EAC/F;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,gBAAgB,GAAA,EAAqB;AACnC,IAAA,MAAM,KAAA,GAAQ,IAAI,IAAA,EAAK;AACvB,IAAA,IAAI,KAAA,CAAM,QAAA,CAAS,GAAG,CAAA,EAAG;AACvB,MAAA,MAAM,CAAC,CAAA,EAAG,CAAC,CAAA,GAAI,KAAA,CAAM,MAAM,GAAG,CAAA,CAAE,GAAA,CAAI,CAAC,SAAS,MAAA,CAAO,UAAA,CAAW,IAAA,CAAK,IAAA,EAAM,CAAC,CAAA;AAC5E,MAAA,IAAI,IAAA,CAAK,WAAA,CAAY,CAAC,CAAA,IAAK,IAAA,CAAK,WAAA,CAAY,CAAC,CAAA,EAAG,OAAO,CAAA,EAAG,CAAC,CAAA,GAAA,EAAM,CAAC,CAAA,CAAA;AAClE,MAAA,OAAO,OAAA;AAAA,IACT;AACA,IAAA,MAAM,MAAA,GAAS,MAAA,CAAO,UAAA,CAAW,KAAK,CAAA;AACtC,IAAA,OAAO,KAAK,WAAA,CAAY,MAAM,CAAA,GAAI,MAAA,CAAO,MAAM,CAAA,GAAI,OAAA;AAAA,EACrD;AAAA,EAEA,YAAY,KAAA,EAA4C;AACtD,IAAA,OAAO,UAAU,MAAA,IAAa,MAAA,CAAO,QAAA,CAAS,KAAK,KAAK,KAAA,GAAQ,CAAA;AAAA,EAClE;AACF","file":"aspect_ratio_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **Aspect Ratio** helper. No APG pattern — a pure layout utility with\n * no role or state.\n *\n * Markup contract (identifier: `stimeo--aspect-ratio`):\n * <div data-controller=\"stimeo--aspect-ratio\"\n * data-stimeo--aspect-ratio-ratio-value=\"16/9\">\n * <img src=\"/cover.jpg\" alt=\"Cover\"\n * data-stimeo--aspect-ratio-target=\"content\" />\n * </div>\n *\n * Supplies the requested ratio as the `--stimeo-aspect-ratio` custom property on\n * the host so consumer CSS can drive the box (`aspect-ratio: var(--stimeo-aspect-ratio)`)\n * or a padding-hack fallback. The drawing itself — `aspect-ratio`, `object-fit`,\n * cropping — stays in the consumer's stylesheet.\n *\n * @remarks\n * Behavior only. The value accepts the CSS `<ratio>` forms `\"16/9\"` and a bare\n * number string (`\"1.5\"`); it is normalized to `\"w / h\"` (or the number) and an\n * unparseable value falls back to the default `1 / 1` rather than writing garbage\n * into the custom property. The reflection re-runs when the value changes.\n */\nexport class AspectRatioController extends Controller<HTMLElement> {\n static override targets = [\"content\"];\n static override values = {\n ratio: { type: String, default: \"1/1\" },\n };\n\n declare ratioValue: string;\n\n /** Applies the ratio on connect and whenever the value changes. */\n ratioValueChanged(): void {\n this.element.style.setProperty(\"--stimeo-aspect-ratio\", this.#normalizeRatio(this.ratioValue));\n }\n\n /**\n * Normalizes a ratio string to a valid CSS `<ratio>`:\n * - `\"16/9\"` / `\"16 / 9\"` → `\"16 / 9\"` (both parts must be positive numbers)\n * - `\"1.5\"` → `\"1.5\"` (a bare positive number)\n * - anything else → `\"1 / 1\"` (the default), so the custom property is always valid.\n */\n #normalizeRatio(raw: string): string {\n const value = raw.trim();\n if (value.includes(\"/\")) {\n const [w, h] = value.split(\"/\").map((part) => Number.parseFloat(part.trim()));\n if (this.#isPositive(w) && this.#isPositive(h)) return `${w} / ${h}`;\n return \"1 / 1\";\n }\n const single = Number.parseFloat(value);\n return this.#isPositive(single) ? String(single) : \"1 / 1\";\n }\n\n #isPositive(value: number | undefined): value is number {\n return value !== undefined && Number.isFinite(value) && value > 0;\n }\n}\n"]}