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,216 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/focus_controller.ts
4
+
5
+ // src/utils/focus_trap.ts
6
+ var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
7
+ var FocusTrap = class {
8
+ /** The element focused before activation, restored on deactivation. */
9
+ #previouslyFocused = null;
10
+ /** The body's inline `overflow` before locking, restored on deactivation. */
11
+ #previousBodyOverflow = "";
12
+ /** Whether scroll was locked this activation (so it is only restored if applied). */
13
+ #scrollLocked = false;
14
+ /** Background siblings made `inert` while active, restored on deactivation. */
15
+ #inertedSiblings = [];
16
+ /** Whether the modal side effects are currently applied. */
17
+ #activeState = false;
18
+ /** Returns the trapped element; called on every operation for the live target. */
19
+ #getContainer;
20
+ /** Closing/focus hooks; see {@link FocusTrapOptions}. */
21
+ #options;
22
+ /**
23
+ * @param getContainer - Returns the trapped element. Called on every operation
24
+ * so the live target is always used.
25
+ * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.
26
+ */
27
+ constructor(getContainer, options = {}) {
28
+ this.#getContainer = getContainer;
29
+ this.#options = options;
30
+ }
31
+ /** Whether the trap is currently active. */
32
+ get active() {
33
+ return this.#activeState;
34
+ }
35
+ /**
36
+ * Applies the trap: records the current focus, optionally locks background scroll
37
+ * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless
38
+ * `autoFocus` is off) moves focus inside. No-ops if already active.
39
+ */
40
+ activate() {
41
+ if (this.#activeState) return;
42
+ this.#activeState = true;
43
+ const active = document.activeElement;
44
+ this.#previouslyFocused = active instanceof HTMLElement && active !== document.body ? active : null;
45
+ if (this.#flag(this.#options.lockScroll, true)) {
46
+ this.#previousBodyOverflow = document.body.style.overflow;
47
+ document.body.style.overflow = "hidden";
48
+ this.#scrollLocked = true;
49
+ }
50
+ if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
51
+ document.addEventListener("keydown", this.#onKeydown);
52
+ if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
53
+ }
54
+ /**
55
+ * Reverts every side effect applied by {@link activate}. No-ops if inactive, so
56
+ * a controller can call it defensively from both `close()` and `disconnect()`.
57
+ *
58
+ * @param restoreFocus - Move focus back to the opener (default `true`). Pass
59
+ * `false` on teardown (`disconnect`), where yanking focus is undesirable.
60
+ */
61
+ deactivate({ restoreFocus = true } = {}) {
62
+ if (!this.#activeState) return;
63
+ this.#activeState = false;
64
+ document.removeEventListener("keydown", this.#onKeydown);
65
+ if (this.#scrollLocked) {
66
+ document.body.style.overflow = this.#previousBodyOverflow;
67
+ this.#scrollLocked = false;
68
+ }
69
+ this.#releaseBackground();
70
+ if (restoreFocus) {
71
+ const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;
72
+ target?.focus();
73
+ }
74
+ }
75
+ /** Resolves a boolean-or-getter option, defaulting when it was not provided. */
76
+ #flag(option, fallback) {
77
+ if (option === void 0) return fallback;
78
+ return typeof option === "function" ? option() : option;
79
+ }
80
+ /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */
81
+ #onKeydown = (event) => {
82
+ if (event.key === "Escape") {
83
+ if (this.#options.onEscape) {
84
+ event.preventDefault();
85
+ this.#options.onEscape();
86
+ }
87
+ return;
88
+ }
89
+ if (event.key === "Tab") this.#trapTab(event);
90
+ };
91
+ /** Keeps `Tab` focus cycling within the container's focusable elements. */
92
+ #trapTab(event) {
93
+ const focusable = this.#focusableElements();
94
+ if (focusable.length === 0) {
95
+ event.preventDefault();
96
+ return;
97
+ }
98
+ const first = focusable[0];
99
+ const last = focusable[focusable.length - 1];
100
+ const active = document.activeElement;
101
+ if (!(active instanceof Node) || !this.#getContainer().contains(active)) {
102
+ event.preventDefault();
103
+ first?.focus();
104
+ return;
105
+ }
106
+ if (event.shiftKey && active === first) {
107
+ event.preventDefault();
108
+ last?.focus();
109
+ } else if (!event.shiftKey && active === last) {
110
+ event.preventDefault();
111
+ first?.focus();
112
+ }
113
+ }
114
+ /**
115
+ * Marks every element outside the container's subtree as `inert` so background
116
+ * content cannot be focused or reached by assistive technology, honoring the
117
+ * `aria-modal="true"` contract. An element that was *already* `inert` is left
118
+ * untracked so `#releaseBackground` does not wrongly clear it.
119
+ */
120
+ #isolateBackground() {
121
+ const container = this.#getContainer();
122
+ this.#inertedSiblings = [];
123
+ for (const sibling of Array.from(document.body.children)) {
124
+ if (!(sibling instanceof HTMLElement)) continue;
125
+ if (sibling.contains(container) || sibling.inert) continue;
126
+ sibling.inert = true;
127
+ this.#inertedSiblings.push(sibling);
128
+ }
129
+ }
130
+ /** Reverts the `inert` flags applied by `#isolateBackground`. */
131
+ #releaseBackground() {
132
+ for (const sibling of this.#inertedSiblings) {
133
+ sibling.inert = false;
134
+ }
135
+ this.#inertedSiblings = [];
136
+ }
137
+ /** Moves focus to the initial target, the first focusable, or the container. */
138
+ #focusInitial() {
139
+ const preferred = this.#options.initialFocus?.();
140
+ if (preferred) {
141
+ preferred.focus();
142
+ return;
143
+ }
144
+ const focusable = this.#focusableElements();
145
+ if (focusable[0]) {
146
+ focusable[0].focus();
147
+ return;
148
+ }
149
+ const container = this.#getContainer();
150
+ container.tabIndex = -1;
151
+ container.focus();
152
+ }
153
+ /** Collects the container's currently focusable descendants in DOM order. */
154
+ #focusableElements() {
155
+ return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
156
+ (el) => !el.hidden
157
+ );
158
+ }
159
+ };
160
+
161
+ // src/controllers/focus_controller.ts
162
+ var FocusController = class extends Controller {
163
+ static targets = ["initial"];
164
+ static values = {
165
+ trap: { type: Boolean, default: false },
166
+ auto: { type: Boolean, default: true },
167
+ restore: { type: Boolean, default: true },
168
+ inert: { type: Boolean, default: false }
169
+ };
170
+ static actions = ["activate", "deactivate"];
171
+ static events = ["activate", "deactivate"];
172
+ #trap = new FocusTrap(() => this.element, {
173
+ // A focus scope is not a modal: never lock scroll, and only isolate the background
174
+ // when `inert` is requested. `auto` gates the initial focus move; Escape releases.
175
+ lockScroll: false,
176
+ isolate: () => this.inertValue,
177
+ autoFocus: () => this.autoValue,
178
+ initialFocus: () => this.hasInitialTarget ? this.initialTarget : null,
179
+ onEscape: () => this.deactivate()
180
+ });
181
+ /** Stimulus drives activation from the `trap` value (also fires on connect). */
182
+ trapValueChanged() {
183
+ if (this.trapValue) this.#activate();
184
+ else this.#deactivate();
185
+ }
186
+ disconnect() {
187
+ this.#trap.deactivate({ restoreFocus: false });
188
+ this.element.removeAttribute("data-focus-trapped");
189
+ }
190
+ /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */
191
+ activate() {
192
+ this.trapValue = true;
193
+ this.#activate();
194
+ }
195
+ /** Turns the trap off (also wired to Escape). */
196
+ deactivate() {
197
+ this.trapValue = false;
198
+ this.#deactivate();
199
+ }
200
+ #activate() {
201
+ if (this.#trap.active) return;
202
+ this.#trap.activate();
203
+ this.element.setAttribute("data-focus-trapped", "true");
204
+ this.dispatch("activate", { detail: {} });
205
+ }
206
+ #deactivate() {
207
+ if (!this.#trap.active) return;
208
+ this.#trap.deactivate({ restoreFocus: this.restoreValue });
209
+ this.element.removeAttribute("data-focus-trapped");
210
+ this.dispatch("deactivate", { detail: {} });
211
+ }
212
+ };
213
+
214
+ export { FocusController };
215
+ //# sourceMappingURL=focus_controller.js.map
216
+ //# sourceMappingURL=focus_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/focus_trap.ts","../../src/controllers/focus_controller.ts"],"names":[],"mappings":"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","file":"focus_controller.js","sourcesContent":["/**\n * Modal focus-trap primitive shared by the modal-overlay controllers\n * (dialog / alert-dialog / drawer).\n *\n * The WAI-ARIA APG modal pattern is more than \"cycle Tab inside a box\": a modal\n * also locks background scroll, makes the rest of the page `inert` (so assistive\n * technology and pointer/Tab cannot reach it, honoring `aria-modal=\"true\"`),\n * sends focus inside on open, and restores it to the opener on close — and every\n * one of those side effects must be reverted if the element is torn down while\n * open (a Turbo navigation mid-dialog). {@link FocusTrap} owns that whole modal\n * lifecycle so each controller only decides *when* to open/close and *what*\n * \"close\" means.\n *\n * It is intentionally **policy-free about closing**. Escape semantics differ per\n * widget (a plain dialog just closes; an alert-dialog closes *as a cancel* with a\n * reason; a drawer runs an exit transition), so the trap merely forwards Escape\n * to an {@link FocusTrapOptions.onEscape | onEscape} callback and never decides on\n * its own what closing entails.\n *\n * @remarks\n * The container is read through a getter so a controller can hand over a Stimulus\n * target without worrying about when the trap instance is constructed relative to\n * `connect()`.\n */\n\n/**\n * Selector matching the elements considered focusable. Shared by the trap's Tab\n * cycling and by form-validation's invalid-focus delegation.\n */\nexport const FOCUSABLE =\n 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\n\n/** Behavior hooks a controller supplies when constructing a {@link FocusTrap}. */\nexport interface FocusTrapOptions {\n /**\n * Called when `Escape` is pressed while the trap is active. When omitted,\n * `Escape` is left alone (the trap never closes itself). The trap calls\n * `preventDefault()` before invoking it.\n */\n onEscape?: () => void;\n /**\n * Returns the element to focus when the trap activates. When it returns `null`\n * (or is omitted), the first focusable descendant is used, falling back to the\n * container itself (made programmatically focusable with `tabindex=-1`).\n */\n initialFocus?: () => HTMLElement | null;\n /**\n * Returns the element to focus on deactivation when nothing was focused before\n * the trap opened (e.g. the trigger). The element focused *before* opening\n * always takes precedence.\n */\n fallbackFocus?: () => HTMLElement | null;\n /**\n * Lock background scroll (`body` overflow) while active. Defaults to `true` for\n * the modal overlays; a lighter focus scope passes `false`. Read on `activate`.\n */\n lockScroll?: boolean | (() => boolean);\n /**\n * Make background siblings `inert` while active (the `aria-modal` isolation).\n * Defaults to `true` for the modal overlays; a soft focus scope can opt out so\n * the background stays reachable while `Tab` still cycles inside. Read on `activate`.\n */\n isolate?: boolean | (() => boolean);\n /**\n * Move focus inside on `activate`. Defaults to `true`; a focus scope that only\n * wants the `Tab` boundary (no focus move) passes `false`. Read on `activate`.\n */\n autoFocus?: boolean | (() => boolean);\n}\n\n/**\n * Owns the modal side effects (scroll lock, background `inert`, focus trap, focus\n * restore) for a single container, applied on {@link activate} and reverted on\n * {@link deactivate}.\n */\nexport class FocusTrap {\n /** The element focused before activation, restored on deactivation. */\n #previouslyFocused: HTMLElement | null = null;\n /** The body's inline `overflow` before locking, restored on deactivation. */\n #previousBodyOverflow = \"\";\n /** Whether scroll was locked this activation (so it is only restored if applied). */\n #scrollLocked = false;\n /** Background siblings made `inert` while active, restored on deactivation. */\n #inertedSiblings: HTMLElement[] = [];\n /** Whether the modal side effects are currently applied. */\n #activeState = false;\n\n /** Returns the trapped element; called on every operation for the live target. */\n readonly #getContainer: () => HTMLElement;\n /** Closing/focus hooks; see {@link FocusTrapOptions}. */\n readonly #options: FocusTrapOptions;\n\n /**\n * @param getContainer - Returns the trapped element. Called on every operation\n * so the live target is always used.\n * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.\n */\n constructor(getContainer: () => HTMLElement, options: FocusTrapOptions = {}) {\n this.#getContainer = getContainer;\n this.#options = options;\n }\n\n /** Whether the trap is currently active. */\n get active(): boolean {\n return this.#activeState;\n }\n\n /**\n * Applies the trap: records the current focus, optionally locks background scroll\n * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless\n * `autoFocus` is off) moves focus inside. No-ops if already active.\n */\n activate(): void {\n if (this.#activeState) return;\n this.#activeState = true;\n // Record the opener so it can be refocused on close. `<body>` (the default\n // active element when nothing is focused) is treated as \"nothing\", so the\n // fallback target — typically the trigger — wins in that case.\n const active = document.activeElement;\n this.#previouslyFocused =\n active instanceof HTMLElement && active !== document.body ? active : null;\n if (this.#flag(this.#options.lockScroll, true)) {\n this.#previousBodyOverflow = document.body.style.overflow;\n document.body.style.overflow = \"hidden\";\n this.#scrollLocked = true;\n }\n if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();\n document.addEventListener(\"keydown\", this.#onKeydown);\n if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();\n }\n\n /**\n * Reverts every side effect applied by {@link activate}. No-ops if inactive, so\n * a controller can call it defensively from both `close()` and `disconnect()`.\n *\n * @param restoreFocus - Move focus back to the opener (default `true`). Pass\n * `false` on teardown (`disconnect`), where yanking focus is undesirable.\n */\n deactivate({ restoreFocus = true }: { restoreFocus?: boolean } = {}): void {\n if (!this.#activeState) return;\n this.#activeState = false;\n document.removeEventListener(\"keydown\", this.#onKeydown);\n if (this.#scrollLocked) {\n document.body.style.overflow = this.#previousBodyOverflow;\n this.#scrollLocked = false;\n }\n this.#releaseBackground();\n if (restoreFocus) {\n const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;\n target?.focus();\n }\n }\n\n /** Resolves a boolean-or-getter option, defaulting when it was not provided. */\n #flag(option: boolean | (() => boolean) | undefined, fallback: boolean): boolean {\n if (option === undefined) return fallback;\n return typeof option === \"function\" ? option() : option;\n }\n\n /** Handles `Escape` (delegated) and `Tab` (focus trap) while active. */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\") {\n if (this.#options.onEscape) {\n event.preventDefault();\n this.#options.onEscape();\n }\n return;\n }\n if (event.key === \"Tab\") this.#trapTab(event);\n };\n\n /** Keeps `Tab` focus cycling within the container's focusable elements. */\n #trapTab(event: KeyboardEvent): void {\n const focusable = this.#focusableElements();\n if (focusable.length === 0) {\n event.preventDefault();\n return;\n }\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n const active = document.activeElement;\n\n // If focus has somehow escaped the container, pull it back to the first item.\n if (!(active instanceof Node) || !this.#getContainer().contains(active)) {\n event.preventDefault();\n first?.focus();\n return;\n }\n\n if (event.shiftKey && active === first) {\n event.preventDefault();\n last?.focus();\n } else if (!event.shiftKey && active === last) {\n event.preventDefault();\n first?.focus();\n }\n }\n\n /**\n * Marks every element outside the container's subtree as `inert` so background\n * content cannot be focused or reached by assistive technology, honoring the\n * `aria-modal=\"true\"` contract. An element that was *already* `inert` is left\n * untracked so `#releaseBackground` does not wrongly clear it.\n */\n #isolateBackground(): void {\n const container = this.#getContainer();\n this.#inertedSiblings = [];\n for (const sibling of Array.from(document.body.children)) {\n if (!(sibling instanceof HTMLElement)) continue;\n if (sibling.contains(container) || sibling.inert) continue;\n sibling.inert = true;\n this.#inertedSiblings.push(sibling);\n }\n }\n\n /** Reverts the `inert` flags applied by `#isolateBackground`. */\n #releaseBackground(): void {\n for (const sibling of this.#inertedSiblings) {\n sibling.inert = false;\n }\n this.#inertedSiblings = [];\n }\n\n /** Moves focus to the initial target, the first focusable, or the container. */\n #focusInitial(): void {\n const preferred = this.#options.initialFocus?.();\n if (preferred) {\n preferred.focus();\n return;\n }\n const focusable = this.#focusableElements();\n if (focusable[0]) {\n focusable[0].focus();\n return;\n }\n const container = this.#getContainer();\n container.tabIndex = -1;\n container.focus();\n }\n\n /** Collects the container's currently focusable descendants in DOM order. */\n #focusableElements(): HTMLElement[] {\n return Array.from(this.#getContainer().querySelectorAll<HTMLElement>(FOCUSABLE)).filter(\n (el) => !el.hidden,\n );\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { FocusTrap } from \"../utils/focus_trap\";\n\n/**\n * Headless **focus scope**: exposes the shared {@link FocusTrap} as a standalone,\n * declarative focus boundary — `Tab` cycling, initial focus, and restore-on-release —\n * for any region, without building a full modal (counterpart to Alpine `focus` /\n * Headless UI's trap). No dedicated APG pattern; it is the primitive the overlay\n * patterns use, surfaced for direct use.\n *\n * Markup contract (identifier: `stimeo--focus`):\n * <div data-controller=\"stimeo--focus\" data-stimeo--focus-trap-value=\"true\">\n * <input data-stimeo--focus-target=\"initial\" />\n * …\n * </div>\n *\n * While `trap` is on, `Tab` / `Shift+Tab` cycle within the element, focus moves to the\n * `initial` target (or the first focusable) when `auto`, `Escape` releases it, and on\n * release focus returns to the opener when `restore`. With `inert` the rest of the page\n * is made `inert` (a hard, modal-style isolation); left off it is a soft boundary —\n * `Tab` still cycles but the background stays reachable. The element carries\n * `data-focus-trapped` while active and emits `activate` / `deactivate`.\n *\n * @remarks\n * Behavior only — it does not open/close or render an overlay (pair with Dialog) and\n * does not move DOM (pair with Portal). It reuses `src/utils/focus_trap.ts`, so it never\n * scroll-locks the page (unlike the modal overlays) and tracks live focusable children\n * (dynamic additions are picked up on the next `Tab`). The opener is recorded on\n * activate and refocused on release if still present. Everything is torn down on\n * `disconnect()` (Turbo navigation included) without yanking focus.\n */\nexport class FocusController extends Controller<HTMLElement> {\n static override targets = [\"initial\"];\n static override values = {\n trap: { type: Boolean, default: false },\n auto: { type: Boolean, default: true },\n restore: { type: Boolean, default: true },\n inert: { type: Boolean, default: false },\n };\n static actions = [\"activate\", \"deactivate\"] as const;\n static events = [\"activate\", \"deactivate\"] as const;\n\n declare readonly initialTarget: HTMLElement;\n declare readonly hasInitialTarget: boolean;\n\n declare trapValue: boolean;\n declare autoValue: boolean;\n declare restoreValue: boolean;\n declare inertValue: boolean;\n\n readonly #trap = new FocusTrap(() => this.element, {\n // A focus scope is not a modal: never lock scroll, and only isolate the background\n // when `inert` is requested. `auto` gates the initial focus move; Escape releases.\n lockScroll: false,\n isolate: () => this.inertValue,\n autoFocus: () => this.autoValue,\n initialFocus: () => (this.hasInitialTarget ? this.initialTarget : null),\n onEscape: () => this.deactivate(),\n });\n\n /** Stimulus drives activation from the `trap` value (also fires on connect). */\n trapValueChanged(): void {\n if (this.trapValue) this.#activate();\n else this.#deactivate();\n }\n\n override disconnect(): void {\n // Release without restoring focus — the element is leaving the DOM. This is a\n // teardown, not a user-driven close, so it intentionally does NOT emit\n // `deactivate` or reset `trapValue` (which would resurrect on a Turbo cache\n // restore); only the public `deactivate()` action fires the event.\n this.#trap.deactivate({ restoreFocus: false });\n this.element.removeAttribute(\"data-focus-trapped\");\n }\n\n /** Turns the trap on. Acts synchronously and keeps the `trap` value in sync. */\n activate(): void {\n this.trapValue = true;\n this.#activate();\n }\n\n /** Turns the trap off (also wired to Escape). */\n deactivate(): void {\n this.trapValue = false;\n this.#deactivate();\n }\n\n #activate(): void {\n if (this.#trap.active) return;\n this.#trap.activate();\n this.element.setAttribute(\"data-focus-trapped\", \"true\");\n this.dispatch(\"activate\", { detail: {} });\n }\n\n #deactivate(): void {\n if (!this.#trap.active) return;\n this.#trap.deactivate({ restoreFocus: this.restoreValue });\n this.element.removeAttribute(\"data-focus-trapped\");\n this.dispatch(\"deactivate\", { detail: {} });\n }\n}\n"]}
@@ -0,0 +1,79 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /** Stimulus action params understood by {@link FormFieldController.setError}. */
4
+ interface SetErrorParams {
5
+ /** The error message to display. */
6
+ message?: string;
7
+ }
8
+ /** An action event carrying Stimulus `data-*-param` values. */
9
+ type ActionEvent = Event & {
10
+ params?: SetErrorParams;
11
+ };
12
+ /**
13
+ * Headless, accessible form-field association behavior.
14
+ *
15
+ * Markup contract (identifier: `stimeo--form-field`):
16
+ * <div data-controller="stimeo--form-field">
17
+ * <label for="email">Email</label>
18
+ * <input id="email" type="email" aria-invalid="false"
19
+ * data-stimeo--form-field-target="control" />
20
+ * <p data-stimeo--form-field-target="description">We'll send a confirmation.</p>
21
+ * <p role="alert" hidden data-stimeo--form-field-target="error"></p>
22
+ * </div>
23
+ *
24
+ * Not an APG widget pattern — this is the wiring substrate behind form controls:
25
+ * it supports **Name, Role, Value** (WCAG 4.1.2) and **error identification**
26
+ * (3.3.1 / 4.1.3) by composing the control's `aria-describedby`, toggling
27
+ * `aria-invalid`, and pointing `aria-errormessage` at the live error region.
28
+ *
29
+ * @remarks
30
+ * Behavior only — it sets semantic attributes and the `hidden` state of the
31
+ * error region; it never validates input (the consumer / server decides) and
32
+ * never styles. The error target should carry `role="alert"` (or an
33
+ * `aria-live` region) so a newly shown message is announced without moving focus.
34
+ *
35
+ * Behavior provided:
36
+ * - On connect, assigns ids to description/error targets and composes the
37
+ * control's `aria-describedby` from them (preserving any pre-existing tokens).
38
+ * - Reflects server-rendered errors: an error target that is already visible and
39
+ * non-empty at connect puts the field into the invalid state (progressive
40
+ * enhancement).
41
+ * - {@link setError} / {@link clearError} drive the invalid state at runtime and
42
+ * dispatch `stimeo--form-field:validate`.
43
+ */
44
+ declare class FormFieldController extends Controller<HTMLElement> {
45
+ #private;
46
+ static targets: string[];
47
+ static values: {
48
+ focusOnError: {
49
+ type: BooleanConstructor;
50
+ default: boolean;
51
+ };
52
+ };
53
+ static actions: readonly ["clearError", "setError"];
54
+ static events: readonly ["validate"];
55
+ readonly controlTarget: HTMLElement;
56
+ readonly hasControlTarget: boolean;
57
+ readonly descriptionTargets: HTMLElement[];
58
+ readonly errorTargets: HTMLElement[];
59
+ readonly hasErrorTarget: boolean;
60
+ focusOnErrorValue: boolean;
61
+ /** Wires ids, captures consumer tokens, and reflects any initial error state. */
62
+ connect(): void;
63
+ /**
64
+ * Marks the field invalid and shows the error message. Bound via `data-action`
65
+ * (`#setError`) or callable directly.
66
+ *
67
+ * @param arg - Either the message string, or the action event whose
68
+ * `data-stimeo--form-field-message-param` supplies it. When no message is
69
+ * resolvable, any already-populated error targets are simply (re)shown.
70
+ */
71
+ setError(arg?: string | ActionEvent): void;
72
+ /**
73
+ * Clears the error: empties and hides every error target and marks the field
74
+ * valid. Bound via `data-action` (`#clearError`) or callable directly.
75
+ */
76
+ clearError(): void;
77
+ }
78
+
79
+ export { FormFieldController };
@@ -0,0 +1,154 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/form_field_controller.ts
4
+
5
+ // src/utils/aria_ids.ts
6
+ var counter = 0;
7
+ function uniqueId(prefix = "stimeo") {
8
+ let candidate;
9
+ do {
10
+ counter += 1;
11
+ candidate = `${prefix}-${counter}`;
12
+ } while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
13
+ return candidate;
14
+ }
15
+ function ensureId(element, prefix = "stimeo") {
16
+ if (element.id) return element.id;
17
+ const id = uniqueId(prefix);
18
+ element.id = id;
19
+ return id;
20
+ }
21
+
22
+ // src/controllers/form_field_controller.ts
23
+ var FormFieldController = class _FormFieldController extends Controller {
24
+ static targets = ["control", "description", "error"];
25
+ static values = {
26
+ focusOnError: { type: Boolean, default: false }
27
+ };
28
+ static actions = ["clearError", "setError"];
29
+ static events = ["validate"];
30
+ /** Root attribute (CSS hook) reflecting the invalid state. */
31
+ static #INVALID_ATTR = "data-stimeo--form-field-invalid";
32
+ /**
33
+ * `aria-describedby` tokens the consumer set on the control that the
34
+ * controller does not own. Captured once so composition never clobbers them.
35
+ */
36
+ #baseDescribedBy = [];
37
+ /** Wires ids, captures consumer tokens, and reflects any initial error state. */
38
+ connect() {
39
+ for (const description of this.descriptionTargets) {
40
+ ensureId(description, "stimeo--form-field-desc");
41
+ }
42
+ for (const error of this.errorTargets) {
43
+ ensureId(error, "stimeo--form-field-error");
44
+ }
45
+ this.#baseDescribedBy = this.#externalDescribedByTokens();
46
+ this.#reflect();
47
+ }
48
+ /**
49
+ * Marks the field invalid and shows the error message. Bound via `data-action`
50
+ * (`#setError`) or callable directly.
51
+ *
52
+ * @param arg - Either the message string, or the action event whose
53
+ * `data-stimeo--form-field-message-param` supplies it. When no message is
54
+ * resolvable, any already-populated error targets are simply (re)shown.
55
+ */
56
+ setError(arg) {
57
+ const message = this.#resolveMessage(arg);
58
+ if (message !== null && this.hasErrorTarget) {
59
+ this.errorTargets[0]?.replaceChildren(document.createTextNode(message));
60
+ }
61
+ for (const error of this.errorTargets) {
62
+ error.hidden = (error.textContent ?? "").trim() === "";
63
+ }
64
+ this.#reflect(true);
65
+ this.dispatch("validate", { detail: { valid: false, message: this.#shownMessage() } });
66
+ if (this.focusOnErrorValue && this.hasControlTarget) {
67
+ this.controlTarget.focus();
68
+ }
69
+ }
70
+ /**
71
+ * Clears the error: empties and hides every error target and marks the field
72
+ * valid. Bound via `data-action` (`#clearError`) or callable directly.
73
+ */
74
+ clearError() {
75
+ for (const error of this.errorTargets) {
76
+ error.replaceChildren();
77
+ error.hidden = true;
78
+ }
79
+ this.#reflect();
80
+ this.dispatch("validate", { detail: { valid: true, message: "" } });
81
+ }
82
+ /**
83
+ * Synchronizes the control's ARIA wiring and the root CSS hook from the current
84
+ * error targets. Idempotent, so it is safe to call on connect and after any
85
+ * change (and survives Turbo morphing).
86
+ *
87
+ * @param force - When `true`, the field is marked invalid regardless of whether
88
+ * a (visible, non-empty) error region exists. {@link setError} passes this so
89
+ * the invalid state holds even with no error target; derivation from the DOM
90
+ * (connect / {@link clearError}) leaves it `false`.
91
+ */
92
+ #reflect(force = false) {
93
+ const shown = this.#shownErrors();
94
+ const invalid = force || shown.length > 0;
95
+ if (invalid) {
96
+ this.element.setAttribute(_FormFieldController.#INVALID_ATTR, "");
97
+ } else {
98
+ this.element.removeAttribute(_FormFieldController.#INVALID_ATTR);
99
+ }
100
+ if (!this.hasControlTarget) return;
101
+ const control = this.controlTarget;
102
+ control.setAttribute("aria-invalid", invalid ? "true" : "false");
103
+ const errorIds = shown.map((error) => error.id);
104
+ const primaryErrorId = errorIds[0];
105
+ if (primaryErrorId) {
106
+ control.setAttribute("aria-errormessage", primaryErrorId);
107
+ } else {
108
+ control.removeAttribute("aria-errormessage");
109
+ }
110
+ const describedBy = [
111
+ ...this.#baseDescribedBy,
112
+ ...this.descriptionTargets.map((description) => description.id),
113
+ ...errorIds
114
+ ];
115
+ if (describedBy.length > 0) {
116
+ control.setAttribute("aria-describedby", describedBy.join(" "));
117
+ } else {
118
+ control.removeAttribute("aria-describedby");
119
+ }
120
+ }
121
+ /** Error targets currently visible and non-empty. */
122
+ #shownErrors() {
123
+ return this.errorTargets.filter(
124
+ (error) => !error.hidden && (error.textContent ?? "").trim() !== ""
125
+ );
126
+ }
127
+ /** Text of the first shown error, for the `validate` event detail. */
128
+ #shownMessage() {
129
+ return (this.#shownErrors()[0]?.textContent ?? "").trim();
130
+ }
131
+ /** Resolves a message from a string argument or an action event's params. */
132
+ #resolveMessage(arg) {
133
+ if (typeof arg === "string") return arg;
134
+ const message = arg?.params?.message;
135
+ return typeof message === "string" ? message : null;
136
+ }
137
+ /**
138
+ * Tokens already in the control's `aria-describedby` that are not ids of this
139
+ * controller's own description/error targets.
140
+ */
141
+ #externalDescribedByTokens() {
142
+ if (!this.hasControlTarget) return [];
143
+ const owned = /* @__PURE__ */ new Set([
144
+ ...this.descriptionTargets.map((description) => description.id),
145
+ ...this.errorTargets.map((error) => error.id)
146
+ ]);
147
+ const existing = this.controlTarget.getAttribute("aria-describedby") ?? "";
148
+ return existing.split(/\s+/).filter((token) => token.length > 0 && !owned.has(token));
149
+ }
150
+ };
151
+
152
+ export { FormFieldController };
153
+ //# sourceMappingURL=form_field_controller.js.map
154
+ //# sourceMappingURL=form_field_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/aria_ids.ts","../../src/controllers/form_field_controller.ts"],"names":[],"mappings":"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","file":"form_field_controller.js","sourcesContent":["/**\n * Minimal id primitives for wiring ARIA relationships.\n *\n * Accessible relationships such as `aria-describedby`, `aria-errormessage`,\n * `aria-labelledby`, and `aria-activedescendant` reference their targets by DOM\n * `id`. When a controller must establish such a relationship but the consumer's\n * markup left the target without an `id`, it needs to mint one that is stable for\n * the page's lifetime and guaranteed not to collide with another generated id.\n *\n * These helpers own *only* id generation/assignment. The linking policy (which\n * attribute, what order, when to add or remove it) stays in each controller —\n * this deliberately avoids a premature generic \"ARIA linker\" abstraction.\n */\n\n/**\n * Monotonic counter backing {@link uniqueId}. Module-scoped so every generated\n * id is unique across all controller instances sharing this module in a document.\n */\nlet counter = 0;\n\n/**\n * Returns a unique, DOM-id-safe string of the form `` `${prefix}-${n}` `` where\n * `n` increases on each call.\n *\n * The counter alone guarantees uniqueness against other *generated* ids, but a\n * consumer-authored element could already own `` `${prefix}-${n}` ``. When a\n * `document` is available the candidate is therefore advanced until no element\n * with that id exists, so a generated id never collides with author markup\n * either — the minimal \"id registry\" guarantee these helpers exist to provide.\n *\n * @param prefix - Leading segment of the id (e.g. a controller's identifier).\n * Defaults to `\"stimeo\"`.\n */\nexport function uniqueId(prefix = \"stimeo\"): string {\n let candidate: string;\n do {\n counter += 1;\n candidate = `${prefix}-${counter}`;\n } while (typeof document !== \"undefined\" && document.getElementById(candidate) !== null);\n return candidate;\n}\n\n/**\n * Returns `element`'s existing `id`, or assigns it a freshly generated\n * {@link uniqueId} (with the given `prefix`) and returns that. Idempotent: an\n * element that already has an id keeps it, so consumer-authored ids are never\n * overwritten.\n *\n * @param element - The element to read or stamp an `id` onto.\n * @param prefix - Prefix forwarded to {@link uniqueId} when one must be generated.\n * @returns The element's id (existing or newly assigned).\n */\nexport function ensureId(element: Element, prefix = \"stimeo\"): string {\n if (element.id) return element.id;\n const id = uniqueId(prefix);\n element.id = id;\n return id;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { ensureId } from \"../utils/aria_ids\";\n\n/** Stimulus action params understood by {@link FormFieldController.setError}. */\ninterface SetErrorParams {\n /** The error message to display. */\n message?: string;\n}\n\n/** An action event carrying Stimulus `data-*-param` values. */\ntype ActionEvent = Event & { params?: SetErrorParams };\n\n/**\n * Headless, accessible form-field association behavior.\n *\n * Markup contract (identifier: `stimeo--form-field`):\n * <div data-controller=\"stimeo--form-field\">\n * <label for=\"email\">Email</label>\n * <input id=\"email\" type=\"email\" aria-invalid=\"false\"\n * data-stimeo--form-field-target=\"control\" />\n * <p data-stimeo--form-field-target=\"description\">We'll send a confirmation.</p>\n * <p role=\"alert\" hidden data-stimeo--form-field-target=\"error\"></p>\n * </div>\n *\n * Not an APG widget pattern — this is the wiring substrate behind form controls:\n * it supports **Name, Role, Value** (WCAG 4.1.2) and **error identification**\n * (3.3.1 / 4.1.3) by composing the control's `aria-describedby`, toggling\n * `aria-invalid`, and pointing `aria-errormessage` at the live error region.\n *\n * @remarks\n * Behavior only — it sets semantic attributes and the `hidden` state of the\n * error region; it never validates input (the consumer / server decides) and\n * never styles. The error target should carry `role=\"alert\"` (or an\n * `aria-live` region) so a newly shown message is announced without moving focus.\n *\n * Behavior provided:\n * - On connect, assigns ids to description/error targets and composes the\n * control's `aria-describedby` from them (preserving any pre-existing tokens).\n * - Reflects server-rendered errors: an error target that is already visible and\n * non-empty at connect puts the field into the invalid state (progressive\n * enhancement).\n * - {@link setError} / {@link clearError} drive the invalid state at runtime and\n * dispatch `stimeo--form-field:validate`.\n */\nexport class FormFieldController extends Controller<HTMLElement> {\n static override targets = [\"control\", \"description\", \"error\"];\n static override values = {\n focusOnError: { type: Boolean, default: false },\n };\n static actions = [\"clearError\", \"setError\"] as const;\n static events = [\"validate\"] as const;\n\n declare readonly controlTarget: HTMLElement;\n declare readonly hasControlTarget: boolean;\n declare readonly descriptionTargets: HTMLElement[];\n declare readonly errorTargets: HTMLElement[];\n declare readonly hasErrorTarget: boolean;\n declare focusOnErrorValue: boolean;\n\n /** Root attribute (CSS hook) reflecting the invalid state. */\n static readonly #INVALID_ATTR = \"data-stimeo--form-field-invalid\";\n\n /**\n * `aria-describedby` tokens the consumer set on the control that the\n * controller does not own. Captured once so composition never clobbers them.\n */\n #baseDescribedBy: string[] = [];\n\n /** Wires ids, captures consumer tokens, and reflects any initial error state. */\n override connect(): void {\n for (const description of this.descriptionTargets) {\n ensureId(description, \"stimeo--form-field-desc\");\n }\n for (const error of this.errorTargets) {\n ensureId(error, \"stimeo--form-field-error\");\n }\n this.#baseDescribedBy = this.#externalDescribedByTokens();\n this.#reflect();\n }\n\n /**\n * Marks the field invalid and shows the error message. Bound via `data-action`\n * (`#setError`) or callable directly.\n *\n * @param arg - Either the message string, or the action event whose\n * `data-stimeo--form-field-message-param` supplies it. When no message is\n * resolvable, any already-populated error targets are simply (re)shown.\n */\n setError(arg?: string | ActionEvent): void {\n const message = this.#resolveMessage(arg);\n if (message !== null && this.hasErrorTarget) {\n this.errorTargets[0]?.replaceChildren(document.createTextNode(message));\n }\n for (const error of this.errorTargets) {\n error.hidden = (error.textContent ?? \"\").trim() === \"\";\n }\n // setError is an explicit invalid request: mark invalid even when the field\n // has no error region (structure rules require only `control`), so the DOM\n // invalid state never disagrees with the dispatched `valid: false`.\n this.#reflect(true);\n this.dispatch(\"validate\", { detail: { valid: false, message: this.#shownMessage() } });\n if (this.focusOnErrorValue && this.hasControlTarget) {\n this.controlTarget.focus();\n }\n }\n\n /**\n * Clears the error: empties and hides every error target and marks the field\n * valid. Bound via `data-action` (`#clearError`) or callable directly.\n */\n clearError(): void {\n for (const error of this.errorTargets) {\n error.replaceChildren();\n error.hidden = true;\n }\n this.#reflect();\n this.dispatch(\"validate\", { detail: { valid: true, message: \"\" } });\n }\n\n /**\n * Synchronizes the control's ARIA wiring and the root CSS hook from the current\n * error targets. Idempotent, so it is safe to call on connect and after any\n * change (and survives Turbo morphing).\n *\n * @param force - When `true`, the field is marked invalid regardless of whether\n * a (visible, non-empty) error region exists. {@link setError} passes this so\n * the invalid state holds even with no error target; derivation from the DOM\n * (connect / {@link clearError}) leaves it `false`.\n */\n #reflect(force = false): void {\n const shown = this.#shownErrors();\n const invalid = force || shown.length > 0;\n\n if (invalid) {\n this.element.setAttribute(FormFieldController.#INVALID_ATTR, \"\");\n } else {\n this.element.removeAttribute(FormFieldController.#INVALID_ATTR);\n }\n\n if (!this.hasControlTarget) return;\n const control = this.controlTarget;\n control.setAttribute(\"aria-invalid\", invalid ? \"true\" : \"false\");\n\n const errorIds = shown.map((error) => error.id);\n // aria-errormessage references a single error element (the widely-supported\n // IDREF form); any additional errors stay in aria-describedby below.\n const primaryErrorId = errorIds[0];\n if (primaryErrorId) {\n control.setAttribute(\"aria-errormessage\", primaryErrorId);\n } else {\n control.removeAttribute(\"aria-errormessage\");\n }\n\n // Compose describedby: consumer tokens, then descriptions, then shown errors\n // (so legacy AT that ignore aria-errormessage still read the error).\n const describedBy = [\n ...this.#baseDescribedBy,\n ...this.descriptionTargets.map((description) => description.id),\n ...errorIds,\n ];\n if (describedBy.length > 0) {\n control.setAttribute(\"aria-describedby\", describedBy.join(\" \"));\n } else {\n control.removeAttribute(\"aria-describedby\");\n }\n }\n\n /** Error targets currently visible and non-empty. */\n #shownErrors(): HTMLElement[] {\n return this.errorTargets.filter(\n (error) => !error.hidden && (error.textContent ?? \"\").trim() !== \"\",\n );\n }\n\n /** Text of the first shown error, for the `validate` event detail. */\n #shownMessage(): string {\n return (this.#shownErrors()[0]?.textContent ?? \"\").trim();\n }\n\n /** Resolves a message from a string argument or an action event's params. */\n #resolveMessage(arg?: string | ActionEvent): string | null {\n if (typeof arg === \"string\") return arg;\n const message = arg?.params?.message;\n return typeof message === \"string\" ? message : null;\n }\n\n /**\n * Tokens already in the control's `aria-describedby` that are not ids of this\n * controller's own description/error targets.\n */\n #externalDescribedByTokens(): string[] {\n if (!this.hasControlTarget) return [];\n const owned = new Set([\n ...this.descriptionTargets.map((description) => description.id),\n ...this.errorTargets.map((error) => error.id),\n ]);\n const existing = this.controlTarget.getAttribute(\"aria-describedby\") ?? \"\";\n return existing.split(/\\s+/).filter((token) => token.length > 0 && !owned.has(token));\n }\n}\n"]}
@@ -0,0 +1,114 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+ import { FormFieldController } from './form_field_controller.js';
3
+
4
+ /**
5
+ * Headless, accessible **form-validation orchestration**.
6
+ *
7
+ * Markup contract (identifier: `stimeo--form-validation`):
8
+ * <form data-controller="stimeo--form-validation"
9
+ * data-stimeo--form-validation-stimeo--form-field-outlet="[data-controller~='stimeo--form-field']">
10
+ * <div data-controller="stimeo--form-field">
11
+ * <label for="email">Email</label>
12
+ * <input id="email" type="email" required
13
+ * data-stimeo--form-field-target="control" />
14
+ * <p role="alert" hidden data-stimeo--form-field-target="error"></p>
15
+ * </div>
16
+ * <button type="submit">Save</button>
17
+ * </form>
18
+ *
19
+ * Not an APG widget pattern — this is the *timing* layer for **error
20
+ * identification** (WCAG 3.3.1) and **error suggestion** (3.3.3): it decides
21
+ * *when* each control is checked and routes the browser's native
22
+ * `validationMessage` into the field's {@link FormFieldController} error region.
23
+ * The per-field ARIA wiring (`aria-invalid` / `aria-errormessage` /
24
+ * `aria-describedby`) therefore lives in exactly one place — `stimeo--form-field`,
25
+ * reached through a Stimulus **outlet** — and is never re-implemented here.
26
+ *
27
+ * @remarks
28
+ * Behavior only — validation **rules** stay in the markup (native HTML
29
+ * constraints: `required`, `type`, `pattern`, `min`/`max`, …) or in the consumer's
30
+ * own `setCustomValidity()` calls, which `checkValidity()` surfaces transparently.
31
+ * This controller never invents rules or messages and never styles. It sets the
32
+ * form's `novalidate` so it can replace the browser's default error bubbles with
33
+ * the accessible, in-page `role="alert"` regions, and restores the attribute on
34
+ * disconnect.
35
+ *
36
+ * Behavior provided:
37
+ * - On connect, suppresses native bubbles (`novalidate`, restored on disconnect)
38
+ * and intercepts the form's `submit` in the **capture phase** so an invalid form
39
+ * is cancelled before any other submit handler (e.g. `stimeo--submit-once`)
40
+ * reacts to a submission that will never happen.
41
+ * - On submit, validates every control; if any is invalid it blocks submission,
42
+ * moves focus to the first invalid control (unless `focusInvalid` is `false`),
43
+ * and dispatches `stimeo--form-validation:invalid`. An all-valid form dispatches
44
+ * `:valid` and submits normally.
45
+ * - Validates a field on blur once it has been interacted with (`validateOnBlur`),
46
+ * and re-validates it on input while it is already touched
47
+ * (`revalidateOnInput`) so a shown message clears the moment the value becomes
48
+ * valid — but a pristine field is never eagerly flagged mid-typing.
49
+ *
50
+ * A control with no owning `stimeo--form-field` outlet is still validated (it can
51
+ * block submit and receive focus) but renders no message.
52
+ *
53
+ * Radio groups work unchanged: point the field's `control` target at the
54
+ * `role="radiogroup"` container so the invalid state lands on the group, and the
55
+ * group is reported as a single invalid entry (not one per radio).
56
+ *
57
+ * Rich widgets (listbox, time-picker, …) that keep their committed value in a
58
+ * hidden holder participate by making that holder a **validatable mirror**:
59
+ * `<input type="text" hidden required>` — the `hidden` *attribute*, not
60
+ * `type="hidden"`, which is barred from constraint validation. Native
61
+ * constraints then govern the widget's value with no extra JavaScript. The
62
+ * widget dispatches a bubbling `change` on the mirror when a value is committed
63
+ * (a completed interaction, so it validates immediately), and focus for an
64
+ * invalid mirror is delegated to the field's visible `control` target — the
65
+ * target itself when focusable, else its first focusable descendant.
66
+ *
67
+ * No-JS caveat: a `required` mirror also gates the browser's own pre-Stimulus
68
+ * validation, which cannot surface UI on an invisible control. When the no-JS
69
+ * fallback matters, author `novalidate` on the form (this controller preserves
70
+ * an author-set attribute) so the submission reaches the server's validation.
71
+ */
72
+ declare class FormValidationController extends Controller<HTMLFormElement> {
73
+ #private;
74
+ static outlets: string[];
75
+ static values: {
76
+ validateOnBlur: {
77
+ type: BooleanConstructor;
78
+ default: boolean;
79
+ };
80
+ validateOnChange: {
81
+ type: BooleanConstructor;
82
+ default: boolean;
83
+ };
84
+ revalidateOnInput: {
85
+ type: BooleanConstructor;
86
+ default: boolean;
87
+ };
88
+ focusInvalid: {
89
+ type: BooleanConstructor;
90
+ default: boolean;
91
+ };
92
+ };
93
+ static actions: readonly ["validate"];
94
+ static events: readonly ["valid", "invalid"];
95
+ readonly stimeoFormFieldOutlets: FormFieldController[];
96
+ readonly stimeoFormFieldOutletElements: HTMLElement[];
97
+ validateOnBlurValue: boolean;
98
+ validateOnChangeValue: boolean;
99
+ revalidateOnInputValue: boolean;
100
+ focusInvalidValue: boolean;
101
+ /** Suppresses native bubbles and binds the submit / blur / input listeners. */
102
+ connect(): void;
103
+ /** Tears down listeners and restores `novalidate` if we added it. */
104
+ disconnect(): void;
105
+ /**
106
+ * Validates every control now, rendering or clearing each field's message, and
107
+ * returns whether the whole form is valid. Marks every control touched so a
108
+ * later input re-validates it. Bound via `data-action`
109
+ * (`#validate`) or callable directly (e.g. before a programmatic submit).
110
+ */
111
+ validate(): boolean;
112
+ }
113
+
114
+ export { FormValidationController };