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,134 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/menu_controller.ts
4
+ var MenuController = class extends Controller {
5
+ static targets = ["trigger", "menu", "item"];
6
+ static actions = [
7
+ "activate",
8
+ "close",
9
+ "onItemKeydown",
10
+ "onTriggerKeydown",
11
+ "open",
12
+ "toggle"
13
+ ];
14
+ /** Starts closed and registers the outside-click listener. */
15
+ connect() {
16
+ this.close();
17
+ document.addEventListener("click", this.#onOutsideClick);
18
+ }
19
+ /** Removes the document-level listener registered in {@link connect}. */
20
+ disconnect() {
21
+ document.removeEventListener("click", this.#onOutsideClick);
22
+ }
23
+ /** Toggles the menu open/closed. Bound via `data-action` (click). */
24
+ toggle() {
25
+ if (this.#isOpen) {
26
+ this.close();
27
+ } else {
28
+ this.open();
29
+ this.#focusFirst();
30
+ }
31
+ }
32
+ /** Opens the menu and reflects the expanded state on the trigger. */
33
+ open() {
34
+ if (!this.hasMenuTarget) return;
35
+ this.menuTarget.hidden = false;
36
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
37
+ }
38
+ /** Closes the menu and reflects the collapsed state on the trigger. */
39
+ close() {
40
+ if (!this.hasMenuTarget) return;
41
+ this.menuTarget.hidden = true;
42
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
43
+ }
44
+ /**
45
+ * Opens the menu with the keyboard per the APG (Down → first, Up → last).
46
+ *
47
+ * Enter/Space are intentionally not handled here: on a native `<button>`
48
+ * trigger the browser turns them into a click, which already runs
49
+ * {@link toggle} (open + focus first item). Handling them again here would
50
+ * open and then immediately re-toggle the menu.
51
+ */
52
+ onTriggerKeydown(event) {
53
+ if (event.key === "ArrowDown") {
54
+ event.preventDefault();
55
+ this.open();
56
+ this.#focusFirst();
57
+ } else if (event.key === "ArrowUp") {
58
+ event.preventDefault();
59
+ this.open();
60
+ this.#focusLast();
61
+ }
62
+ }
63
+ /** Implements roving focus and closing keys inside the menu. */
64
+ onItemKeydown(event) {
65
+ const items = this.#navigableItems;
66
+ const currentIndex = items.indexOf(event.currentTarget);
67
+ switch (event.key) {
68
+ case "ArrowDown":
69
+ event.preventDefault();
70
+ if (items.length > 0) items[(currentIndex + 1) % items.length]?.focus();
71
+ break;
72
+ case "ArrowUp":
73
+ event.preventDefault();
74
+ if (items.length > 0) items[(currentIndex - 1 + items.length) % items.length]?.focus();
75
+ break;
76
+ case "Home":
77
+ event.preventDefault();
78
+ items[0]?.focus();
79
+ break;
80
+ case "End":
81
+ event.preventDefault();
82
+ items[items.length - 1]?.focus();
83
+ break;
84
+ case "Escape":
85
+ event.preventDefault();
86
+ this.close();
87
+ if (this.hasTriggerTarget) this.triggerTarget.focus();
88
+ break;
89
+ case "Tab":
90
+ this.close();
91
+ break;
92
+ }
93
+ }
94
+ /** Closes the menu after an item is activated. Bound via `data-action`. */
95
+ activate() {
96
+ this.close();
97
+ if (this.hasTriggerTarget) this.triggerTarget.focus();
98
+ }
99
+ /** Closes the menu when a click lands outside the controller's element. */
100
+ #onOutsideClick = (event) => {
101
+ if (this.#isOpen && !this.element.contains(event.target)) this.close();
102
+ };
103
+ /** Moves focus to the first navigable item (no-op if none). */
104
+ #focusFirst() {
105
+ this.#navigableItems[0]?.focus();
106
+ }
107
+ /** Moves focus to the last navigable item (no-op if none). */
108
+ #focusLast() {
109
+ const items = this.#navigableItems;
110
+ items[items.length - 1]?.focus();
111
+ }
112
+ /** Menu items eligible for roving focus (excludes disabled / hidden). */
113
+ get #navigableItems() {
114
+ return this.itemTargets.filter((item) => this.#isNavigable(item));
115
+ }
116
+ /**
117
+ * An item can take roving focus unless it is `hidden`, `aria-disabled="true"`,
118
+ * or a natively `disabled` form control. CSS-only visibility is not detectable
119
+ * here and is the consumer's responsibility.
120
+ */
121
+ #isNavigable(item) {
122
+ if (item.hasAttribute("hidden")) return false;
123
+ if (item.getAttribute("aria-disabled") === "true") return false;
124
+ return !item.disabled;
125
+ }
126
+ /** Whether the menu is currently visible. */
127
+ get #isOpen() {
128
+ return this.hasMenuTarget && !this.menuTarget.hidden;
129
+ }
130
+ };
131
+
132
+ export { MenuController };
133
+ //# sourceMappingURL=menu_controller.js.map
134
+ //# sourceMappingURL=menu_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/menu_controller.ts"],"names":[],"mappings":"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","file":"menu_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible menu button behavior.\n *\n * Markup contract (identifier: `stimeo--menu`):\n * <div data-controller=\"stimeo--menu\">\n * <button data-stimeo--menu-target=\"trigger\"\n * data-action=\"click->stimeo--menu#toggle\n * keydown->stimeo--menu#onTriggerKeydown\"\n * aria-haspopup=\"menu\" aria-expanded=\"false\" aria-controls=\"menu\">\n * Actions\n * </button>\n * <ul id=\"menu\" role=\"menu\" data-stimeo--menu-target=\"menu\" hidden>\n * <li role=\"none\">\n * <button role=\"menuitem\" tabindex=\"-1\"\n * data-stimeo--menu-target=\"item\"\n * data-action=\"click->stimeo--menu#activate\n * keydown->stimeo--menu#onItemKeydown\">…</button>\n * </li>\n * </ul>\n * </div>\n *\n * Implements the WAI-ARIA APG **Menu Button** pattern (a button that opens a menu\n * of commands). Unlike `stimeo--dropdown` (a disclosure for arbitrary content),\n * this is a true `role=\"menu\"` widget with roving focus across `role=\"menuitem\"`\n * children.\n *\n * @remarks\n * Behavior only — static placement is the consumer's responsibility (CSS), and\n * viewport-edge collision handling is intentionally out of scope (a future shared\n * positioning module will own it). State is exposed via `aria-expanded` and the\n * menu's `hidden` attribute.\n *\n * Behavior provided:\n * - Click the trigger to toggle; `ArrowDown`/`ArrowUp` open and focus the\n * first/last item.\n * - Within the menu, `ArrowDown`/`ArrowUp` move focus (wrapping), `Home`/`End`\n * jump to the first/last item, `Escape`/`Tab` close and return focus to the\n * trigger, and activating an item closes the menu.\n * - A click outside the controller closes the menu.\n *\n * Roving focus skips items that are not navigable — `hidden`, natively\n * `disabled`, or `aria-disabled=\"true\"` — so the keyboard never lands focus on an\n * inert command (matching `stimeo--command-palette` / `stimeo--toolbar`).\n */\nexport class MenuController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"menu\", \"item\"];\n static actions = [\n \"activate\",\n \"close\",\n \"onItemKeydown\",\n \"onTriggerKeydown\",\n \"open\",\n \"toggle\",\n ] as const;\n\n declare readonly triggerTarget: HTMLButtonElement;\n declare readonly menuTarget: HTMLElement;\n declare readonly itemTargets: HTMLButtonElement[];\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasMenuTarget: boolean;\n\n /** Starts closed and registers the outside-click listener. */\n override connect(): void {\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick);\n }\n\n /** Removes the document-level listener registered in {@link connect}. */\n override disconnect(): void {\n document.removeEventListener(\"click\", this.#onOutsideClick);\n }\n\n /** Toggles the menu open/closed. Bound via `data-action` (click). */\n toggle(): void {\n if (this.#isOpen) {\n this.close();\n } else {\n this.open();\n this.#focusFirst();\n }\n }\n\n /** Opens the menu and reflects the expanded state on the trigger. */\n open(): void {\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = false;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n }\n\n /** Closes the menu and reflects the collapsed state on the trigger. */\n close(): void {\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = true;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /**\n * Opens the menu with the keyboard per the APG (Down → first, Up → last).\n *\n * Enter/Space are intentionally not handled here: on a native `<button>`\n * trigger the browser turns them into a click, which already runs\n * {@link toggle} (open + focus first item). Handling them again here would\n * open and then immediately re-toggle the menu.\n */\n onTriggerKeydown(event: KeyboardEvent): void {\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n this.open();\n this.#focusFirst();\n } else if (event.key === \"ArrowUp\") {\n event.preventDefault();\n this.open();\n this.#focusLast();\n }\n }\n\n /** Implements roving focus and closing keys inside the menu. */\n onItemKeydown(event: KeyboardEvent): void {\n const items = this.#navigableItems;\n const currentIndex = items.indexOf(event.currentTarget as HTMLButtonElement);\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n if (items.length > 0) items[(currentIndex + 1) % items.length]?.focus();\n break;\n case \"ArrowUp\":\n event.preventDefault();\n if (items.length > 0) items[(currentIndex - 1 + items.length) % items.length]?.focus();\n break;\n case \"Home\":\n event.preventDefault();\n items[0]?.focus();\n break;\n case \"End\":\n event.preventDefault();\n items[items.length - 1]?.focus();\n break;\n case \"Escape\":\n event.preventDefault();\n this.close();\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n break;\n case \"Tab\":\n // Per APG, Tab closes the menu but focus moves on naturally (it is not\n // returned to the trigger — that is Escape's job).\n this.close();\n break;\n default:\n break;\n }\n }\n\n /** Closes the menu after an item is activated. Bound via `data-action`. */\n activate(): void {\n this.close();\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n }\n\n /** Closes the menu when a click lands outside the controller's element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (this.#isOpen && !this.element.contains(event.target as Node)) this.close();\n };\n\n /** Moves focus to the first navigable item (no-op if none). */\n #focusFirst(): void {\n this.#navigableItems[0]?.focus();\n }\n\n /** Moves focus to the last navigable item (no-op if none). */\n #focusLast(): void {\n const items = this.#navigableItems;\n items[items.length - 1]?.focus();\n }\n\n /** Menu items eligible for roving focus (excludes disabled / hidden). */\n get #navigableItems(): HTMLButtonElement[] {\n return this.itemTargets.filter((item) => this.#isNavigable(item));\n }\n\n /**\n * An item can take roving focus unless it is `hidden`, `aria-disabled=\"true\"`,\n * or a natively `disabled` form control. CSS-only visibility is not detectable\n * here and is the consumer's responsibility.\n */\n #isNavigable(item: HTMLButtonElement): boolean {\n if (item.hasAttribute(\"hidden\")) return false;\n if (item.getAttribute(\"aria-disabled\") === \"true\") return false;\n return !item.disabled;\n }\n\n /** Whether the menu is currently visible. */\n get #isOpen(): boolean {\n return this.hasMenuTarget && !this.menuTarget.hidden;\n }\n}\n"]}
@@ -0,0 +1,93 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Event shape `setValue` accepts: an action param `amount` or a `detail.value`.
5
+ * Both are typed `number | string` because, while Stimulus coerces numeric action
6
+ * params to numbers, a `meter:set` CustomEvent (or a non-numeric-looking param)
7
+ * may carry a string; {@link MeterController.setValue} normalizes either form.
8
+ */
9
+ type SetValueEvent = Event & {
10
+ params?: {
11
+ amount?: number | string;
12
+ };
13
+ detail?: {
14
+ value?: number | string;
15
+ };
16
+ };
17
+ /**
18
+ * Headless meter behavior backed by the WAI-ARIA `meter` role.
19
+ *
20
+ * Markup contract (identifier: `stimeo--meter`):
21
+ * <div data-controller="stimeo--meter" role="meter" aria-label="Disk usage"
22
+ * aria-valuemin="0" aria-valuemax="100" aria-valuenow="72"
23
+ * data-stimeo--meter-value-value="72"
24
+ * data-stimeo--meter-low-value="50" data-stimeo--meter-high-value="80">
25
+ * <div data-stimeo--meter-target="bar"></div>
26
+ * </div>
27
+ *
28
+ * A `meter` is a *point-in-time* scalar within a known range (disk usage,
29
+ * battery, score) — distinct from {@link ProgressController}'s task progress.
30
+ * The controller syncs the ARIA value attributes and, when `low`/`high`
31
+ * thresholds are present, classifies the value into a `low`/`medium`/`high`
32
+ * segment on `data-state` so the consumer can color the bar.
33
+ *
34
+ * @remarks
35
+ * Behavior only. Because state must not be conveyed by color alone (WCAG 1.4.1),
36
+ * a consumer-provided `valueText` template feeds `aria-valuetext` so the segment
37
+ * is also available as text. Threshold presence is read from the *attributes*
38
+ * (an absent attribute means "no threshold"), not from a sentinel value.
39
+ */
40
+ declare class MeterController extends Controller<HTMLElement> {
41
+ #private;
42
+ static targets: string[];
43
+ static values: {
44
+ value: {
45
+ type: NumberConstructor;
46
+ default: number;
47
+ };
48
+ min: {
49
+ type: NumberConstructor;
50
+ default: number;
51
+ };
52
+ max: {
53
+ type: NumberConstructor;
54
+ default: number;
55
+ };
56
+ low: {
57
+ type: NumberConstructor;
58
+ default: number;
59
+ };
60
+ high: {
61
+ type: NumberConstructor;
62
+ default: number;
63
+ };
64
+ optimum: {
65
+ type: NumberConstructor;
66
+ default: number;
67
+ };
68
+ valueText: {
69
+ type: StringConstructor;
70
+ default: string;
71
+ };
72
+ };
73
+ static actions: readonly ["setValue"];
74
+ static events: readonly ["change"];
75
+ readonly barTarget: HTMLElement;
76
+ readonly hasBarTarget: boolean;
77
+ valueValue: number;
78
+ minValue: number;
79
+ maxValue: number;
80
+ lowValue: number;
81
+ highValue: number;
82
+ optimumValue: number;
83
+ valueTextValue: string;
84
+ connect(): void;
85
+ /**
86
+ * Updates the measured value from an action param (`amount`) or a
87
+ * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches
88
+ * `change` with the value, ratio, and computed segment.
89
+ */
90
+ setValue(event: SetValueEvent): void;
91
+ }
92
+
93
+ export { MeterController };
@@ -0,0 +1,96 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/meter_controller.ts
4
+
5
+ // src/utils/coerce.ts
6
+ function toFiniteNumber(raw) {
7
+ if (raw === null || raw === void 0 || raw === "") return null;
8
+ const value = typeof raw === "number" ? raw : Number(raw);
9
+ return Number.isFinite(value) ? value : null;
10
+ }
11
+
12
+ // src/controllers/meter_controller.ts
13
+ var MeterController = class extends Controller {
14
+ static targets = ["bar"];
15
+ static values = {
16
+ value: { type: Number, default: 0 },
17
+ min: { type: Number, default: 0 },
18
+ max: { type: Number, default: 100 },
19
+ low: { type: Number, default: 0 },
20
+ high: { type: Number, default: 0 },
21
+ optimum: { type: Number, default: 0 },
22
+ valueText: { type: String, default: "" }
23
+ };
24
+ static actions = ["setValue"];
25
+ static events = ["change"];
26
+ connect() {
27
+ this.#render();
28
+ }
29
+ /**
30
+ * Updates the measured value from an action param (`amount`) or a
31
+ * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches
32
+ * `change` with the value, ratio, and computed segment.
33
+ */
34
+ setValue(event) {
35
+ const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);
36
+ if (next === null) return;
37
+ this.valueValue = this.#clamp(next);
38
+ this.#render();
39
+ this.dispatch("change", {
40
+ detail: { value: this.valueValue, ratio: this.#ratio, state: this.#state }
41
+ });
42
+ }
43
+ /** Clamps `raw` into the configured `[min, max]` range. */
44
+ #clamp(raw) {
45
+ return Math.min(this.maxValue, Math.max(this.minValue, raw));
46
+ }
47
+ /** Current fraction of the range in `[0, 1]`; `0` when the range is empty. */
48
+ get #ratio() {
49
+ const span = this.maxValue - this.minValue;
50
+ if (span <= 0) return 0;
51
+ return (this.#clamp(this.valueValue) - this.minValue) / span;
52
+ }
53
+ /** Whether a threshold attribute is present (absent = no threshold). */
54
+ #hasThreshold(name) {
55
+ return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);
56
+ }
57
+ /**
58
+ * Classifies the value into a `low`/`medium`/`high` segment. Values at or
59
+ * below `low` are `low`; at or above `high` are `high`; otherwise `medium`.
60
+ * With neither threshold present, everything is `medium`.
61
+ */
62
+ get #state() {
63
+ const value = this.#clamp(this.valueValue);
64
+ if (this.#hasThreshold("low") && value <= this.lowValue) return "low";
65
+ if (this.#hasThreshold("high") && value >= this.highValue) return "high";
66
+ return "medium";
67
+ }
68
+ /** Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio. */
69
+ #render() {
70
+ const value = this.#clamp(this.valueValue);
71
+ this.element.setAttribute("aria-valuemin", String(this.minValue));
72
+ this.element.setAttribute("aria-valuemax", String(this.maxValue));
73
+ this.element.setAttribute("aria-valuenow", String(value));
74
+ this.element.style.setProperty("--stimeo-meter-ratio", String(this.#ratio));
75
+ this.element.setAttribute("data-state", this.#state);
76
+ this.#applyValueText(value);
77
+ }
78
+ /**
79
+ * Sets `aria-valuetext` from the consumer-provided template, substituting
80
+ * `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library;
81
+ * cleared when no template is given.
82
+ */
83
+ #applyValueText(value) {
84
+ if (this.valueTextValue.length === 0) {
85
+ this.element.removeAttribute("aria-valuetext");
86
+ return;
87
+ }
88
+ const percent = Math.round(this.#ratio * 100);
89
+ const text = this.valueTextValue.replaceAll("{value}", String(value)).replaceAll("{percent}", String(percent)).replaceAll("{state}", this.#state);
90
+ this.element.setAttribute("aria-valuetext", text);
91
+ }
92
+ };
93
+
94
+ export { MeterController };
95
+ //# sourceMappingURL=meter_controller.js.map
96
+ //# sourceMappingURL=meter_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/coerce.ts","../../src/controllers/meter_controller.ts"],"names":[],"mappings":";;;;;AAeO,SAAS,eAAe,GAAA,EAAwD;AACrF,EAAA,IAAI,QAAQ,IAAA,IAAQ,GAAA,KAAQ,MAAA,IAAa,GAAA,KAAQ,IAAI,OAAO,IAAA;AAC5D,EAAA,MAAM,QAAQ,OAAO,GAAA,KAAQ,QAAA,GAAW,GAAA,GAAM,OAAO,GAAG,CAAA;AACxD,EAAA,OAAO,MAAA,CAAO,QAAA,CAAS,KAAK,CAAA,GAAI,KAAA,GAAQ,IAAA;AAC1C;;;ACqBO,IAAM,eAAA,GAAN,cAA8B,UAAA,CAAwB;AAAA,EAC3D,OAAgB,OAAA,GAAU,CAAC,KAAK,CAAA;AAAA,EAChC,OAAgB,MAAA,GAAS;AAAA,IACvB,KAAA,EAAO,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IAClC,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IAChC,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,GAAA,EAAI;AAAA,IAClC,GAAA,EAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IAChC,IAAA,EAAM,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IACjC,OAAA,EAAS,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,CAAA,EAAE;AAAA,IACpC,SAAA,EAAW,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA;AAAG,GACzC;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,UAAU,CAAA;AAAA,EAC5B,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA,EAahB,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,OAAA,EAAQ;AAAA,EACf;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,SAAS,KAAA,EAA4B;AACnC,IAAA,MAAM,OAAO,cAAA,CAAe,KAAA,CAAM,QAAQ,MAAA,IAAU,KAAA,CAAM,QAAQ,KAAK,CAAA;AACvE,IAAA,IAAI,SAAS,IAAA,EAAM;AACnB,IAAA,IAAA,CAAK,UAAA,GAAa,IAAA,CAAK,MAAA,CAAO,IAAI,CAAA;AAClC,IAAA,IAAA,CAAK,OAAA,EAAQ;AACb,IAAA,IAAA,CAAK,SAAS,QAAA,EAAU;AAAA,MACtB,MAAA,EAAQ,EAAE,KAAA,EAAO,IAAA,CAAK,UAAA,EAAY,OAAO,IAAA,CAAK,MAAA,EAAQ,KAAA,EAAO,IAAA,CAAK,MAAA;AAAO,KAC1E,CAAA;AAAA,EACH;AAAA;AAAA,EAGA,OAAO,GAAA,EAAqB;AAC1B,IAAA,OAAO,IAAA,CAAK,IAAI,IAAA,CAAK,QAAA,EAAU,KAAK,GAAA,CAAI,IAAA,CAAK,QAAA,EAAU,GAAG,CAAC,CAAA;AAAA,EAC7D;AAAA;AAAA,EAGA,IAAI,MAAA,GAAiB;AACnB,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,QAAA,GAAW,IAAA,CAAK,QAAA;AAClC,IAAA,IAAI,IAAA,IAAQ,GAAG,OAAO,CAAA;AACtB,IAAA,OAAA,CAAQ,KAAK,MAAA,CAAO,IAAA,CAAK,UAAU,CAAA,GAAI,KAAK,QAAA,IAAY,IAAA;AAAA,EAC1D;AAAA;AAAA,EAGA,cAAc,IAAA,EAA+B;AAC3C,IAAA,OAAO,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,CAAA,mBAAA,EAAsB,IAAI,CAAA,MAAA,CAAQ,CAAA;AAAA,EACrE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,IAAI,MAAA,GAAqB;AACvB,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,MAAA,CAAO,IAAA,CAAK,UAAU,CAAA;AACzC,IAAA,IAAI,KAAK,aAAA,CAAc,KAAK,KAAK,KAAA,IAAS,IAAA,CAAK,UAAU,OAAO,KAAA;AAChE,IAAA,IAAI,KAAK,aAAA,CAAc,MAAM,KAAK,KAAA,IAAS,IAAA,CAAK,WAAW,OAAO,MAAA;AAClE,IAAA,OAAO,QAAA;AAAA,EACT;AAAA;AAAA,EAGA,OAAA,GAAgB;AACd,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,MAAA,CAAO,IAAA,CAAK,UAAU,CAAA;AACzC,IAAA,IAAA,CAAK,QAAQ,YAAA,CAAa,eAAA,EAAiB,MAAA,CAAO,IAAA,CAAK,QAAQ,CAAC,CAAA;AAChE,IAAA,IAAA,CAAK,QAAQ,YAAA,CAAa,eAAA,EAAiB,MAAA,CAAO,IAAA,CAAK,QAAQ,CAAC,CAAA;AAChE,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,eAAA,EAAiB,MAAA,CAAO,KAAK,CAAC,CAAA;AACxD,IAAA,IAAA,CAAK,QAAQ,KAAA,CAAM,WAAA,CAAY,wBAAwB,MAAA,CAAO,IAAA,CAAK,MAAM,CAAC,CAAA;AAC1E,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,IAAA,CAAK,MAAM,CAAA;AACnD,IAAA,IAAA,CAAK,gBAAgB,KAAK,CAAA;AAAA,EAC5B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,gBAAgB,KAAA,EAAqB;AACnC,IAAA,IAAI,IAAA,CAAK,cAAA,CAAe,MAAA,KAAW,CAAA,EAAG;AACpC,MAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,gBAAgB,CAAA;AAC7C,MAAA;AAAA,IACF;AACA,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,SAAS,GAAG,CAAA;AAC5C,IAAA,MAAM,OAAO,IAAA,CAAK,cAAA,CACf,WAAW,SAAA,EAAW,MAAA,CAAO,KAAK,CAAC,CAAA,CACnC,UAAA,CAAW,WAAA,EAAa,OAAO,OAAO,CAAC,EACvC,UAAA,CAAW,SAAA,EAAW,KAAK,MAAM,CAAA;AACpC,IAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,gBAAA,EAAkB,IAAI,CAAA;AAAA,EAClD;AACF","file":"meter_controller.js","sourcesContent":["/**\n * Numeric coercion shared by the value-bearing status controllers (progress,\n * meter).\n *\n * Stimulus already coerces numeric action params to numbers, but a value can also\n * arrive as a string — via a `*:set` CustomEvent `detail`, or an action param\n * whose attribute does not look numeric. Centralizing the parse keeps `setValue`\n * tolerant of either form while rejecting anything that is not a finite number.\n */\n\n/**\n * Coerces `raw` to a finite number, or returns `null` when it is absent, empty,\n * or not parseable. Empty strings are treated as \"no value\" rather than `0`, so a\n * stray blank param cannot silently reset the value.\n */\nexport function toFiniteNumber(raw: number | string | null | undefined): number | null {\n if (raw === null || raw === undefined || raw === \"\") return null;\n const value = typeof raw === \"number\" ? raw : Number(raw);\n return Number.isFinite(value) ? value : null;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { toFiniteNumber } from \"../utils/coerce\";\n\n/**\n * Event shape `setValue` accepts: an action param `amount` or a `detail.value`.\n * Both are typed `number | string` because, while Stimulus coerces numeric action\n * params to numbers, a `meter:set` CustomEvent (or a non-numeric-looking param)\n * may carry a string; {@link MeterController.setValue} normalizes either form.\n */\ntype SetValueEvent = Event & {\n params?: { amount?: number | string };\n detail?: { value?: number | string };\n};\n\n/** Threshold segment a value falls into, reflected on `data-state`. */\ntype MeterState = \"low\" | \"medium\" | \"high\";\n\n/**\n * Headless meter behavior backed by the WAI-ARIA `meter` role.\n *\n * Markup contract (identifier: `stimeo--meter`):\n * <div data-controller=\"stimeo--meter\" role=\"meter\" aria-label=\"Disk usage\"\n * aria-valuemin=\"0\" aria-valuemax=\"100\" aria-valuenow=\"72\"\n * data-stimeo--meter-value-value=\"72\"\n * data-stimeo--meter-low-value=\"50\" data-stimeo--meter-high-value=\"80\">\n * <div data-stimeo--meter-target=\"bar\"></div>\n * </div>\n *\n * A `meter` is a *point-in-time* scalar within a known range (disk usage,\n * battery, score) — distinct from {@link ProgressController}'s task progress.\n * The controller syncs the ARIA value attributes and, when `low`/`high`\n * thresholds are present, classifies the value into a `low`/`medium`/`high`\n * segment on `data-state` so the consumer can color the bar.\n *\n * @remarks\n * Behavior only. Because state must not be conveyed by color alone (WCAG 1.4.1),\n * a consumer-provided `valueText` template feeds `aria-valuetext` so the segment\n * is also available as text. Threshold presence is read from the *attributes*\n * (an absent attribute means \"no threshold\"), not from a sentinel value.\n */\nexport class MeterController extends Controller<HTMLElement> {\n static override targets = [\"bar\"];\n static override values = {\n value: { type: Number, default: 0 },\n min: { type: Number, default: 0 },\n max: { type: Number, default: 100 },\n low: { type: Number, default: 0 },\n high: { type: Number, default: 0 },\n optimum: { type: Number, default: 0 },\n valueText: { type: String, default: \"\" },\n };\n static actions = [\"setValue\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly barTarget: HTMLElement;\n declare readonly hasBarTarget: boolean;\n\n declare valueValue: number;\n declare minValue: number;\n declare maxValue: number;\n declare lowValue: number;\n declare highValue: number;\n declare optimumValue: number;\n declare valueTextValue: string;\n\n override connect(): void {\n this.#render();\n }\n\n /**\n * Updates the measured value from an action param (`amount`) or a\n * `detail.value` CustomEvent, syncs ARIA and `data-state`, and dispatches\n * `change` with the value, ratio, and computed segment.\n */\n setValue(event: SetValueEvent): void {\n const next = toFiniteNumber(event.params?.amount ?? event.detail?.value);\n if (next === null) return;\n this.valueValue = this.#clamp(next);\n this.#render();\n this.dispatch(\"change\", {\n detail: { value: this.valueValue, ratio: this.#ratio, state: this.#state },\n });\n }\n\n /** Clamps `raw` into the configured `[min, max]` range. */\n #clamp(raw: number): number {\n return Math.min(this.maxValue, Math.max(this.minValue, raw));\n }\n\n /** Current fraction of the range in `[0, 1]`; `0` when the range is empty. */\n get #ratio(): number {\n const span = this.maxValue - this.minValue;\n if (span <= 0) return 0;\n return (this.#clamp(this.valueValue) - this.minValue) / span;\n }\n\n /** Whether a threshold attribute is present (absent = no threshold). */\n #hasThreshold(name: \"low\" | \"high\"): boolean {\n return this.element.hasAttribute(`data-stimeo--meter-${name}-value`);\n }\n\n /**\n * Classifies the value into a `low`/`medium`/`high` segment. Values at or\n * below `low` are `low`; at or above `high` are `high`; otherwise `medium`.\n * With neither threshold present, everything is `medium`.\n */\n get #state(): MeterState {\n const value = this.#clamp(this.valueValue);\n if (this.#hasThreshold(\"low\") && value <= this.lowValue) return \"low\";\n if (this.#hasThreshold(\"high\") && value >= this.highValue) return \"high\";\n return \"medium\";\n }\n\n /** Reflects value/range onto ARIA, the segment onto `data-state`, and the ratio. */\n #render(): void {\n const value = this.#clamp(this.valueValue);\n this.element.setAttribute(\"aria-valuemin\", String(this.minValue));\n this.element.setAttribute(\"aria-valuemax\", String(this.maxValue));\n this.element.setAttribute(\"aria-valuenow\", String(value));\n this.element.style.setProperty(\"--stimeo-meter-ratio\", String(this.#ratio));\n this.element.setAttribute(\"data-state\", this.#state);\n this.#applyValueText(value);\n }\n\n /**\n * Sets `aria-valuetext` from the consumer-provided template, substituting\n * `{value}`, `{percent}`, and `{state}`. Kept i18n-neutral in the library;\n * cleared when no template is given.\n */\n #applyValueText(value: number): void {\n if (this.valueTextValue.length === 0) {\n this.element.removeAttribute(\"aria-valuetext\");\n return;\n }\n const percent = Math.round(this.#ratio * 100);\n const text = this.valueTextValue\n .replaceAll(\"{value}\", String(value))\n .replaceAll(\"{percent}\", String(percent))\n .replaceAll(\"{state}\", this.#state);\n this.element.setAttribute(\"aria-valuetext\", text);\n }\n}\n"]}
@@ -0,0 +1,85 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless **nested / dynamic fields** for Rails `fields_for` +
5
+ * `accepts_nested_attributes_for` (no dedicated APG pattern — form editing). The
6
+ * Headless successor to the cocoon / nested_form gems: clone a `<template>` row,
7
+ * renumber its index, and remove rows by flagging `_destroy` (persisted) or
8
+ * dropping them from the DOM (unsaved).
9
+ *
10
+ * Markup contract (identifier: `stimeo--nested-form`):
11
+ * <div data-controller="stimeo--nested-form" data-stimeo--nested-form-min-value="1">
12
+ * <div data-stimeo--nested-form-target="list">
13
+ * <!-- existing rows; a persisted row carries a _destroy flag + remove button -->
14
+ * </div>
15
+ * <template data-stimeo--nested-form-target="template">
16
+ * <fieldset>
17
+ * <input name="order[items_attributes][__INDEX__][name]">
18
+ * <button type="button" data-stimeo--nested-form-target="remove">Remove</button>
19
+ * </fieldset>
20
+ * </template>
21
+ * <button type="button" data-stimeo--nested-form-target="add"
22
+ * data-action="click->stimeo--nested-form#add">Add</button>
23
+ * </div>
24
+ *
25
+ * @remarks
26
+ * Behavior only — server-side `accepts_nested_attributes_for`, per-field
27
+ * validation, and reordering are out of scope. Row state lives **only** in the DOM
28
+ * (inserted nodes + each `_destroy` hidden input); there is no module-scope index
29
+ * counter, so the controller stays idempotent across Turbo swaps. Remove buttons
30
+ * are handled by **delegation** on the container, so dynamically-added rows work
31
+ * without per-row `data-action` (see `stimulus-lifecycle-turbo`). Adding a row moves
32
+ * focus to its first control and removing returns focus to a neighbor (WCAG 2.2
33
+ * 2.4.3); count changes are announced through the shared `stimeo--announcer`
34
+ * (WCAG 2.2 4.1.3) when `announce` + `countMessage` are set. The delegated listener
35
+ * is removed on `disconnect()`.
36
+ */
37
+ declare class NestedFormController extends Controller<HTMLElement> {
38
+ #private;
39
+ static targets: string[];
40
+ static values: {
41
+ min: {
42
+ type: NumberConstructor;
43
+ default: number;
44
+ };
45
+ max: {
46
+ type: NumberConstructor;
47
+ default: number;
48
+ };
49
+ indexPlaceholder: {
50
+ type: StringConstructor;
51
+ default: string;
52
+ };
53
+ announce: {
54
+ type: BooleanConstructor;
55
+ default: boolean;
56
+ };
57
+ countMessage: {
58
+ type: StringConstructor;
59
+ default: string;
60
+ };
61
+ };
62
+ static actions: readonly ["add"];
63
+ static events: readonly ["add", "remove"];
64
+ readonly listTarget: HTMLElement;
65
+ readonly templateTarget: HTMLTemplateElement;
66
+ readonly addTarget: HTMLButtonElement;
67
+ readonly hasListTarget: boolean;
68
+ readonly hasTemplateTarget: boolean;
69
+ readonly hasAddTarget: boolean;
70
+ minValue: number;
71
+ maxValue: number;
72
+ indexPlaceholderValue: string;
73
+ announceValue: boolean;
74
+ countMessageValue: string;
75
+ connect(): void;
76
+ disconnect(): void;
77
+ /**
78
+ * Clones the template row, replaces the index placeholder with a unique value,
79
+ * appends it, focuses its first control, and announces the new count. No-ops at
80
+ * `max`.
81
+ */
82
+ add(): void;
83
+ }
84
+
85
+ export { NestedFormController };
@@ -0,0 +1,131 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/nested_form_controller.ts
4
+ var NestedFormController = class extends Controller {
5
+ static targets = ["list", "template", "add", "remove", "destroyFlag"];
6
+ static values = {
7
+ min: { type: Number, default: 0 },
8
+ max: { type: Number, default: 0 },
9
+ indexPlaceholder: { type: String, default: "__INDEX__" },
10
+ announce: { type: Boolean, default: true },
11
+ countMessage: { type: String, default: "" }
12
+ };
13
+ static actions = ["add"];
14
+ static events = ["add", "remove"];
15
+ /** Monotonic source for unique row indices; never a row-state counter. */
16
+ #lastIndex = 0;
17
+ /** Delegated click handler for the per-row remove buttons (dynamic-safe). */
18
+ #onClick = (event) => {
19
+ const target = event.target;
20
+ const button = target?.closest('[data-stimeo--nested-form-target="remove"]');
21
+ if (!button || !this.element.contains(button)) return;
22
+ const row = this.#rowContaining(button);
23
+ if (row) this.#removeRow(row);
24
+ };
25
+ connect() {
26
+ this.element.addEventListener("click", this.#onClick);
27
+ this.#refresh();
28
+ }
29
+ disconnect() {
30
+ this.element.removeEventListener("click", this.#onClick);
31
+ }
32
+ /**
33
+ * Clones the template row, replaces the index placeholder with a unique value,
34
+ * appends it, focuses its first control, and announces the new count. No-ops at
35
+ * `max`.
36
+ */
37
+ add() {
38
+ if (!this.hasTemplateTarget || !this.hasListTarget || this.#atMax) return;
39
+ const index = this.#nextIndex();
40
+ const markup = this.templateTarget.innerHTML.replaceAll(
41
+ this.indexPlaceholderValue,
42
+ String(index)
43
+ );
44
+ this.listTarget.insertAdjacentHTML("beforeend", markup);
45
+ const row = this.listTarget.lastElementChild;
46
+ if (!row) return;
47
+ this.#refresh();
48
+ this.#firstControl(row)?.focus();
49
+ this.dispatch("add", { detail: { index, element: row } });
50
+ this.#announce();
51
+ }
52
+ /**
53
+ * Removes a row: a persisted row (one carrying a `destroyFlag`) has its flag set
54
+ * to `1` and is hidden so Rails destroys it on submit; an unsaved row is dropped
55
+ * from the DOM. Returns focus to a neighboring row. No-ops at `min`.
56
+ */
57
+ #removeRow(row) {
58
+ if (this.#effectiveRows.length <= this.minValue) return;
59
+ const neighbors = this.#effectiveRows;
60
+ const position = neighbors.indexOf(row);
61
+ const neighbor = neighbors[position + 1] ?? neighbors[position - 1] ?? null;
62
+ const flag = row.querySelector(
63
+ '[data-stimeo--nested-form-target="destroyFlag"]'
64
+ );
65
+ const persisted = flag !== null;
66
+ if (persisted) {
67
+ flag.value = "1";
68
+ row.hidden = true;
69
+ } else {
70
+ row.remove();
71
+ }
72
+ this.#refresh();
73
+ const focusTarget = neighbor ? this.#firstControl(neighbor) : this.hasAddTarget ? this.addTarget : null;
74
+ focusTarget?.focus();
75
+ this.dispatch("remove", { detail: { element: row, persisted } });
76
+ this.#announce();
77
+ }
78
+ /** Recomputes the live count and the min/max state hooks from the DOM. */
79
+ #refresh() {
80
+ const count = this.#effectiveRows.length;
81
+ this.element.setAttribute("data-nested-count", String(count));
82
+ this.#reflect("data-nested-at-max", this.maxValue > 0 && count >= this.maxValue);
83
+ this.#reflect("data-nested-at-min", count <= this.minValue);
84
+ if (this.hasAddTarget) this.addTarget.disabled = this.#atMax;
85
+ }
86
+ /** Bridges the count change to the shared announcer when configured. */
87
+ #announce() {
88
+ if (!this.announceValue || !this.countMessageValue) return;
89
+ const message = this.countMessageValue.replaceAll(
90
+ "{count}",
91
+ String(this.#effectiveRows.length)
92
+ );
93
+ window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
94
+ }
95
+ /** Sets `attribute` to `"true"` when `on`, else removes it. */
96
+ #reflect(attribute, on) {
97
+ if (on) this.element.setAttribute(attribute, "true");
98
+ else this.element.removeAttribute(attribute);
99
+ }
100
+ /** A strictly-increasing unique index (collision-free even on rapid adds). */
101
+ #nextIndex() {
102
+ const index = Math.max(Date.now(), this.#lastIndex + 1);
103
+ this.#lastIndex = index;
104
+ return index;
105
+ }
106
+ get #atMax() {
107
+ return this.maxValue > 0 && this.#effectiveRows.length >= this.maxValue;
108
+ }
109
+ /** Direct child rows of the list that are not flagged for destruction. */
110
+ get #effectiveRows() {
111
+ return Array.from(this.listTarget.children).filter((row) => !row.hidden);
112
+ }
113
+ /** The nearest ancestor of `el` that is a direct child of the list, else null. */
114
+ #rowContaining(el) {
115
+ let node = el;
116
+ while (node && node.parentElement !== this.listTarget) {
117
+ node = node.parentElement;
118
+ }
119
+ return node;
120
+ }
121
+ /** First visible focusable control inside `row` (skips hidden inputs). */
122
+ #firstControl(row) {
123
+ return row.querySelector(
124
+ 'input:not([type="hidden"]), select, textarea, button, [tabindex]'
125
+ );
126
+ }
127
+ };
128
+
129
+ export { NestedFormController };
130
+ //# sourceMappingURL=nested_form_controller.js.map
131
+ //# sourceMappingURL=nested_form_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/nested_form_controller.ts"],"names":[],"mappings":"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","file":"nested_form_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless **nested / dynamic fields** for Rails `fields_for` +\n * `accepts_nested_attributes_for` (no dedicated APG pattern — form editing). The\n * Headless successor to the cocoon / nested_form gems: clone a `<template>` row,\n * renumber its index, and remove rows by flagging `_destroy` (persisted) or\n * dropping them from the DOM (unsaved).\n *\n * Markup contract (identifier: `stimeo--nested-form`):\n * <div data-controller=\"stimeo--nested-form\" data-stimeo--nested-form-min-value=\"1\">\n * <div data-stimeo--nested-form-target=\"list\">\n * <!-- existing rows; a persisted row carries a _destroy flag + remove button -->\n * </div>\n * <template data-stimeo--nested-form-target=\"template\">\n * <fieldset>\n * <input name=\"order[items_attributes][__INDEX__][name]\">\n * <button type=\"button\" data-stimeo--nested-form-target=\"remove\">Remove</button>\n * </fieldset>\n * </template>\n * <button type=\"button\" data-stimeo--nested-form-target=\"add\"\n * data-action=\"click->stimeo--nested-form#add\">Add</button>\n * </div>\n *\n * @remarks\n * Behavior only — server-side `accepts_nested_attributes_for`, per-field\n * validation, and reordering are out of scope. Row state lives **only** in the DOM\n * (inserted nodes + each `_destroy` hidden input); there is no module-scope index\n * counter, so the controller stays idempotent across Turbo swaps. Remove buttons\n * are handled by **delegation** on the container, so dynamically-added rows work\n * without per-row `data-action` (see `stimulus-lifecycle-turbo`). Adding a row moves\n * focus to its first control and removing returns focus to a neighbor (WCAG 2.2\n * 2.4.3); count changes are announced through the shared `stimeo--announcer`\n * (WCAG 2.2 4.1.3) when `announce` + `countMessage` are set. The delegated listener\n * is removed on `disconnect()`.\n */\nexport class NestedFormController extends Controller<HTMLElement> {\n static override targets = [\"list\", \"template\", \"add\", \"remove\", \"destroyFlag\"];\n static override values = {\n min: { type: Number, default: 0 },\n max: { type: Number, default: 0 },\n indexPlaceholder: { type: String, default: \"__INDEX__\" },\n announce: { type: Boolean, default: true },\n countMessage: { type: String, default: \"\" },\n };\n static actions = [\"add\"] as const;\n static events = [\"add\", \"remove\"] as const;\n\n declare readonly listTarget: HTMLElement;\n declare readonly templateTarget: HTMLTemplateElement;\n declare readonly addTarget: HTMLButtonElement;\n declare readonly hasListTarget: boolean;\n declare readonly hasTemplateTarget: boolean;\n declare readonly hasAddTarget: boolean;\n\n declare minValue: number;\n declare maxValue: number;\n declare indexPlaceholderValue: string;\n declare announceValue: boolean;\n declare countMessageValue: string;\n\n /** Monotonic source for unique row indices; never a row-state counter. */\n #lastIndex = 0;\n\n /** Delegated click handler for the per-row remove buttons (dynamic-safe). */\n readonly #onClick = (event: Event): void => {\n const target = event.target as HTMLElement | null;\n const button = target?.closest('[data-stimeo--nested-form-target=\"remove\"]');\n if (!button || !this.element.contains(button)) return;\n const row = this.#rowContaining(button);\n if (row) this.#removeRow(row);\n };\n\n override connect(): void {\n this.element.addEventListener(\"click\", this.#onClick);\n this.#refresh();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"click\", this.#onClick);\n }\n\n /**\n * Clones the template row, replaces the index placeholder with a unique value,\n * appends it, focuses its first control, and announces the new count. No-ops at\n * `max`.\n */\n add(): void {\n if (!this.hasTemplateTarget || !this.hasListTarget || this.#atMax) return;\n\n const index = this.#nextIndex();\n const markup = this.templateTarget.innerHTML.replaceAll(\n this.indexPlaceholderValue,\n String(index),\n );\n this.listTarget.insertAdjacentHTML(\"beforeend\", markup);\n const row = this.listTarget.lastElementChild as HTMLElement | null;\n if (!row) return;\n\n this.#refresh();\n this.#firstControl(row)?.focus();\n this.dispatch(\"add\", { detail: { index, element: row } });\n this.#announce();\n }\n\n /**\n * Removes a row: a persisted row (one carrying a `destroyFlag`) has its flag set\n * to `1` and is hidden so Rails destroys it on submit; an unsaved row is dropped\n * from the DOM. Returns focus to a neighboring row. No-ops at `min`.\n */\n #removeRow(row: HTMLElement): void {\n if (this.#effectiveRows.length <= this.minValue) return;\n\n const neighbors = this.#effectiveRows;\n const position = neighbors.indexOf(row);\n const neighbor = neighbors[position + 1] ?? neighbors[position - 1] ?? null;\n\n const flag = row.querySelector<HTMLInputElement>(\n '[data-stimeo--nested-form-target=\"destroyFlag\"]',\n );\n const persisted = flag !== null;\n if (persisted) {\n flag.value = \"1\";\n row.hidden = true;\n } else {\n row.remove();\n }\n\n this.#refresh();\n const focusTarget = neighbor\n ? this.#firstControl(neighbor)\n : this.hasAddTarget\n ? this.addTarget\n : null;\n focusTarget?.focus();\n this.dispatch(\"remove\", { detail: { element: row, persisted } });\n this.#announce();\n }\n\n /** Recomputes the live count and the min/max state hooks from the DOM. */\n #refresh(): void {\n const count = this.#effectiveRows.length;\n this.element.setAttribute(\"data-nested-count\", String(count));\n this.#reflect(\"data-nested-at-max\", this.maxValue > 0 && count >= this.maxValue);\n this.#reflect(\"data-nested-at-min\", count <= this.minValue);\n if (this.hasAddTarget) this.addTarget.disabled = this.#atMax;\n }\n\n /** Bridges the count change to the shared announcer when configured. */\n #announce(): void {\n if (!this.announceValue || !this.countMessageValue) return;\n const message = this.countMessageValue.replaceAll(\n \"{count}\",\n String(this.#effectiveRows.length),\n );\n window.dispatchEvent(new CustomEvent(\"stimeo--announcer:announce\", { detail: { message } }));\n }\n\n /** Sets `attribute` to `\"true\"` when `on`, else removes it. */\n #reflect(attribute: string, on: boolean): void {\n if (on) this.element.setAttribute(attribute, \"true\");\n else this.element.removeAttribute(attribute);\n }\n\n /** A strictly-increasing unique index (collision-free even on rapid adds). */\n #nextIndex(): number {\n const index = Math.max(Date.now(), this.#lastIndex + 1);\n this.#lastIndex = index;\n return index;\n }\n\n get #atMax(): boolean {\n return this.maxValue > 0 && this.#effectiveRows.length >= this.maxValue;\n }\n\n /** Direct child rows of the list that are not flagged for destruction. */\n get #effectiveRows(): HTMLElement[] {\n return (Array.from(this.listTarget.children) as HTMLElement[]).filter((row) => !row.hidden);\n }\n\n /** The nearest ancestor of `el` that is a direct child of the list, else null. */\n #rowContaining(el: Element): HTMLElement | null {\n let node: Element | null = el;\n while (node && node.parentElement !== this.listTarget) {\n node = node.parentElement;\n }\n return node as HTMLElement | null;\n }\n\n /** First visible focusable control inside `row` (skips hidden inputs). */\n #firstControl(row: HTMLElement): HTMLElement | null {\n return row.querySelector<HTMLElement>(\n 'input:not([type=\"hidden\"]), select, textarea, button, [tabindex]',\n );\n }\n}\n"]}