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,202 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/form_validation_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
+
8
+ // src/controllers/form_validation_controller.ts
9
+ var FormValidationController = class _FormValidationController extends Controller {
10
+ static outlets = ["stimeo--form-field"];
11
+ static values = {
12
+ validateOnBlur: { type: Boolean, default: true },
13
+ validateOnChange: { type: Boolean, default: true },
14
+ revalidateOnInput: { type: Boolean, default: true },
15
+ focusInvalid: { type: Boolean, default: true }
16
+ };
17
+ static actions = ["validate"];
18
+ static events = ["valid", "invalid"];
19
+ /** Marker recording that we added `novalidate`, so we only remove our own. */
20
+ static #NOVALIDATE_MARKER = "data-stimeo--form-validation-novalidate";
21
+ /** Controls already interacted with — the gate for blur / input (re)validation. */
22
+ #touched = /* @__PURE__ */ new WeakSet();
23
+ #onSubmit = (event) => {
24
+ if (event.target !== this.element) return;
25
+ const invalid = this.#validateAll();
26
+ if (invalid.length === 0) {
27
+ this.dispatch("valid", { detail: {} });
28
+ return;
29
+ }
30
+ event.preventDefault();
31
+ event.stopImmediatePropagation();
32
+ const first = invalid[0];
33
+ if (this.focusInvalidValue && first) this.#focusTargetFor(first)?.focus();
34
+ this.dispatch("invalid", { detail: { invalid } });
35
+ };
36
+ #onFocusOut = (event) => {
37
+ if (!this.validateOnBlurValue) return;
38
+ const control = this.#controlFrom(event.target);
39
+ if (!control) return;
40
+ const field = this.#fieldFor(control);
41
+ const related = event.relatedTarget;
42
+ if (field && related instanceof Node && field.element.contains(related)) return;
43
+ this.#touched.add(control);
44
+ this.#validateControl(control);
45
+ };
46
+ #onInput = (event) => {
47
+ if (!this.revalidateOnInputValue) return;
48
+ const control = this.#controlFrom(event.target);
49
+ if (!control || !this.#touched.has(control)) return;
50
+ this.#validateControl(control);
51
+ };
52
+ #onChange = (event) => {
53
+ if (!this.validateOnChangeValue) return;
54
+ const control = this.#controlFrom(event.target);
55
+ if (!control) return;
56
+ this.#touched.add(control);
57
+ this.#validateControl(control);
58
+ };
59
+ /** Suppresses native bubbles and binds the submit / blur / input listeners. */
60
+ connect() {
61
+ if (!this.element.hasAttribute("novalidate")) {
62
+ this.element.setAttribute("novalidate", "");
63
+ this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
64
+ }
65
+ document.addEventListener("submit", this.#onSubmit, true);
66
+ this.element.addEventListener("focusout", this.#onFocusOut);
67
+ this.element.addEventListener("input", this.#onInput);
68
+ this.element.addEventListener("change", this.#onChange);
69
+ }
70
+ /** Tears down listeners and restores `novalidate` if we added it. */
71
+ disconnect() {
72
+ document.removeEventListener("submit", this.#onSubmit, true);
73
+ this.element.removeEventListener("focusout", this.#onFocusOut);
74
+ this.element.removeEventListener("input", this.#onInput);
75
+ this.element.removeEventListener("change", this.#onChange);
76
+ if (this.element.hasAttribute(_FormValidationController.#NOVALIDATE_MARKER)) {
77
+ this.element.removeAttribute("novalidate");
78
+ this.element.removeAttribute(_FormValidationController.#NOVALIDATE_MARKER);
79
+ }
80
+ }
81
+ /**
82
+ * Validates every control now, rendering or clearing each field's message, and
83
+ * returns whether the whole form is valid. Marks every control touched so a
84
+ * later input re-validates it. Bound via `data-action`
85
+ * (`#validate`) or callable directly (e.g. before a programmatic submit).
86
+ */
87
+ validate() {
88
+ return this.#validateAll().length === 0;
89
+ }
90
+ /**
91
+ * Validates every control and returns one invalid control per field. Controls
92
+ * are grouped by field first (see {@link #keyFor}) so a field with several
93
+ * controls — a radio group, or a mirror plus its visible control — reflects
94
+ * *all* of them: a valid sibling must never clear an invalid one's message.
95
+ * Each group's first invalid control supplies the message and the focus target.
96
+ */
97
+ #validateAll() {
98
+ const groups = /* @__PURE__ */ new Map();
99
+ for (const control of this.#controls) {
100
+ this.#touched.add(control);
101
+ const field = this.#fieldFor(control);
102
+ const key = this.#keyFor(control, field);
103
+ const group = groups.get(key);
104
+ if (group) {
105
+ group.controls.push(control);
106
+ } else {
107
+ groups.set(key, { field, controls: [control] });
108
+ }
109
+ }
110
+ const invalid = [];
111
+ for (const group of groups.values()) {
112
+ const firstInvalid = this.#applyGroup(group);
113
+ if (firstInvalid) invalid.push(firstInvalid);
114
+ }
115
+ return invalid;
116
+ }
117
+ /** Re-validates the whole field a single control belongs to (or that control). */
118
+ #validateControl(control) {
119
+ const field = this.#fieldFor(control);
120
+ const key = this.#keyFor(control, field);
121
+ const controls = this.#controls.filter(
122
+ (other) => this.#keyFor(other, this.#fieldFor(other)) === key
123
+ );
124
+ this.#applyGroup({ field, controls });
125
+ }
126
+ /**
127
+ * Runs native constraint validation across a field's controls and routes the
128
+ * result to its `stimeo--form-field` outlet: the first invalid control's
129
+ * `validationMessage` is shown, an all-valid field is cleared. Returns the
130
+ * first invalid control (for the invalid list / focus), or `null` when valid.
131
+ * Routing goes through the outlet, so the ARIA wiring is never duplicated here.
132
+ */
133
+ #applyGroup(group) {
134
+ const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;
135
+ if (group.field) {
136
+ if (firstInvalid) {
137
+ group.field.setError(firstInvalid.validationMessage);
138
+ } else {
139
+ group.field.clearError();
140
+ }
141
+ }
142
+ return firstInvalid;
143
+ }
144
+ /**
145
+ * A grouping key that collects controls belonging to the same field: the owning
146
+ * `stimeo--form-field` when present, else a radio group's shared `name`, else
147
+ * the control itself (always distinct).
148
+ */
149
+ #keyFor(control, field) {
150
+ if (field) return field;
151
+ if (control instanceof HTMLInputElement && control.type === "radio" && control.name) {
152
+ return `radio:${control.name}`;
153
+ }
154
+ return control;
155
+ }
156
+ /**
157
+ * Where focus should land for an invalid control. A visible control is focused
158
+ * directly (status quo for native fields and radios). A validatable mirror
159
+ * (the `hidden` attribute) cannot receive focus, so focus is delegated to the
160
+ * visible widget: the owning field's `control` target when it is itself
161
+ * focusable, else its first focusable descendant (e.g. a roving-tabindex
162
+ * member). Resolved structurally — never by probing `focus()` — so behavior
163
+ * is deterministic and CSS-independent.
164
+ */
165
+ #focusTargetFor(control) {
166
+ if (!control.hidden) return control;
167
+ const field = this.#fieldFor(control);
168
+ if (!field?.hasControlTarget) return null;
169
+ const root = field.controlTarget;
170
+ if (root.matches(FOCUSABLE)) return root;
171
+ return root.querySelector(FOCUSABLE);
172
+ }
173
+ /** The `stimeo--form-field` outlet whose element contains `control`, if any. */
174
+ #fieldFor(control) {
175
+ const elements = this.stimeoFormFieldOutletElements;
176
+ for (let index = 0; index < elements.length; index++) {
177
+ if (elements[index]?.contains(control)) return this.stimeoFormFieldOutlets[index];
178
+ }
179
+ return void 0;
180
+ }
181
+ /** This form's native controls that participate in constraint validation. */
182
+ get #controls() {
183
+ const controls = [];
184
+ for (const element of Array.from(this.element.elements)) {
185
+ if (this.#isValidatable(element)) controls.push(element);
186
+ }
187
+ return controls;
188
+ }
189
+ /** Narrows an event target to a validatable control. */
190
+ #controlFrom(target) {
191
+ return target instanceof Element && this.#isValidatable(target) ? target : null;
192
+ }
193
+ #isValidatable(element) {
194
+ return (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && // `willValidate` already excludes disabled, read-only, hidden, and button
195
+ // controls — the exact set barred from constraint validation.
196
+ element.willValidate;
197
+ }
198
+ };
199
+
200
+ export { FormValidationController };
201
+ //# sourceMappingURL=form_validation_controller.js.map
202
+ //# sourceMappingURL=form_validation_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/focus_trap.ts","../../src/controllers/form_validation_controller.ts"],"names":[],"mappings":"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","file":"form_validation_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 { FOCUSABLE } from \"../utils/focus_trap\";\nimport type { FormFieldController } from \"./form_field_controller\";\n\n/** Native form controls that participate in constraint validation. */\ntype ValidatableControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;\n\n/** A field's validatable controls, grouped so the whole field validates together. */\ninterface FieldGroup {\n readonly field: FormFieldController | undefined;\n readonly controls: ValidatableControl[];\n}\n\n/**\n * Headless, accessible **form-validation orchestration**.\n *\n * Markup contract (identifier: `stimeo--form-validation`):\n * <form data-controller=\"stimeo--form-validation\"\n * data-stimeo--form-validation-stimeo--form-field-outlet=\"[data-controller~='stimeo--form-field']\">\n * <div data-controller=\"stimeo--form-field\">\n * <label for=\"email\">Email</label>\n * <input id=\"email\" type=\"email\" required\n * data-stimeo--form-field-target=\"control\" />\n * <p role=\"alert\" hidden data-stimeo--form-field-target=\"error\"></p>\n * </div>\n * <button type=\"submit\">Save</button>\n * </form>\n *\n * Not an APG widget pattern — this is the *timing* layer for **error\n * identification** (WCAG 3.3.1) and **error suggestion** (3.3.3): it decides\n * *when* each control is checked and routes the browser's native\n * `validationMessage` into the field's {@link FormFieldController} error region.\n * The per-field ARIA wiring (`aria-invalid` / `aria-errormessage` /\n * `aria-describedby`) therefore lives in exactly one place — `stimeo--form-field`,\n * reached through a Stimulus **outlet** — and is never re-implemented here.\n *\n * @remarks\n * Behavior only — validation **rules** stay in the markup (native HTML\n * constraints: `required`, `type`, `pattern`, `min`/`max`, …) or in the consumer's\n * own `setCustomValidity()` calls, which `checkValidity()` surfaces transparently.\n * This controller never invents rules or messages and never styles. It sets the\n * form's `novalidate` so it can replace the browser's default error bubbles with\n * the accessible, in-page `role=\"alert\"` regions, and restores the attribute on\n * disconnect.\n *\n * Behavior provided:\n * - On connect, suppresses native bubbles (`novalidate`, restored on disconnect)\n * and intercepts the form's `submit` in the **capture phase** so an invalid form\n * is cancelled before any other submit handler (e.g. `stimeo--submit-once`)\n * reacts to a submission that will never happen.\n * - On submit, validates every control; if any is invalid it blocks submission,\n * moves focus to the first invalid control (unless `focusInvalid` is `false`),\n * and dispatches `stimeo--form-validation:invalid`. An all-valid form dispatches\n * `:valid` and submits normally.\n * - Validates a field on blur once it has been interacted with (`validateOnBlur`),\n * and re-validates it on input while it is already touched\n * (`revalidateOnInput`) so a shown message clears the moment the value becomes\n * valid — but a pristine field is never eagerly flagged mid-typing.\n *\n * A control with no owning `stimeo--form-field` outlet is still validated (it can\n * block submit and receive focus) but renders no message.\n *\n * Radio groups work unchanged: point the field's `control` target at the\n * `role=\"radiogroup\"` container so the invalid state lands on the group, and the\n * group is reported as a single invalid entry (not one per radio).\n *\n * Rich widgets (listbox, time-picker, …) that keep their committed value in a\n * hidden holder participate by making that holder a **validatable mirror**:\n * `<input type=\"text\" hidden required>` — the `hidden` *attribute*, not\n * `type=\"hidden\"`, which is barred from constraint validation. Native\n * constraints then govern the widget's value with no extra JavaScript. The\n * widget dispatches a bubbling `change` on the mirror when a value is committed\n * (a completed interaction, so it validates immediately), and focus for an\n * invalid mirror is delegated to the field's visible `control` target — the\n * target itself when focusable, else its first focusable descendant.\n *\n * No-JS caveat: a `required` mirror also gates the browser's own pre-Stimulus\n * validation, which cannot surface UI on an invisible control. When the no-JS\n * fallback matters, author `novalidate` on the form (this controller preserves\n * an author-set attribute) so the submission reaches the server's validation.\n */\nexport class FormValidationController extends Controller<HTMLFormElement> {\n static override outlets = [\"stimeo--form-field\"];\n static override values = {\n validateOnBlur: { type: Boolean, default: true },\n validateOnChange: { type: Boolean, default: true },\n revalidateOnInput: { type: Boolean, default: true },\n focusInvalid: { type: Boolean, default: true },\n };\n static actions = [\"validate\"] as const;\n static events = [\"valid\", \"invalid\"] as const;\n\n declare readonly stimeoFormFieldOutlets: FormFieldController[];\n declare readonly stimeoFormFieldOutletElements: HTMLElement[];\n\n declare validateOnBlurValue: boolean;\n declare validateOnChangeValue: boolean;\n declare revalidateOnInputValue: boolean;\n declare focusInvalidValue: boolean;\n\n /** Marker recording that we added `novalidate`, so we only remove our own. */\n static readonly #NOVALIDATE_MARKER = \"data-stimeo--form-validation-novalidate\";\n\n /** Controls already interacted with — the gate for blur / input (re)validation. */\n readonly #touched = new WeakSet<ValidatableControl>();\n\n readonly #onSubmit = (event: SubmitEvent): void => {\n if (event.target !== this.element) return;\n const invalid = this.#validateAll();\n if (invalid.length === 0) {\n this.dispatch(\"valid\", { detail: {} });\n return;\n }\n // Cancel the whole submit: preventDefault stops native/Turbo navigation;\n // stopImmediatePropagation keeps later submit handlers (e.g. submit-once's\n // busy state) from acting on a submission that will never happen.\n event.preventDefault();\n event.stopImmediatePropagation();\n const first = invalid[0];\n if (this.focusInvalidValue && first) this.#focusTargetFor(first)?.focus();\n this.dispatch(\"invalid\", { detail: { invalid } });\n };\n\n readonly #onFocusOut = (event: FocusEvent): void => {\n if (!this.validateOnBlurValue) return;\n const control = this.#controlFrom(event.target);\n if (!control) return;\n // Focus moving *within* the same field (e.g. between members of a radio\n // group) is not leaving it — defer validation until focus actually exits.\n const field = this.#fieldFor(control);\n const related = event.relatedTarget;\n if (field && related instanceof Node && field.element.contains(related)) return;\n this.#touched.add(control);\n this.#validateControl(control);\n };\n\n readonly #onInput = (event: Event): void => {\n if (!this.revalidateOnInputValue) return;\n const control = this.#controlFrom(event.target);\n // Only re-validate a field the user has already left once, so the first\n // keystroke never eagerly flags a control they are still filling in.\n if (!control || !this.#touched.has(control)) return;\n this.#validateControl(control);\n };\n\n readonly #onChange = (event: Event): void => {\n if (!this.validateOnChangeValue) return;\n const control = this.#controlFrom(event.target);\n if (!control) return;\n // change marks a *committed* interaction (a picked option, a toggled box, a\n // widget writing its mirror), so unlike input it both touches and validates.\n this.#touched.add(control);\n this.#validateControl(control);\n };\n\n /** Suppresses native bubbles and binds the submit / blur / input listeners. */\n override connect(): void {\n if (!this.element.hasAttribute(\"novalidate\")) {\n this.element.setAttribute(\"novalidate\", \"\");\n this.element.setAttribute(FormValidationController.#NOVALIDATE_MARKER, \"\");\n }\n // Capture phase on the document so we run before any submit listener bound to\n // the form itself (Stimulus actions, submit-once), whose relative order in the\n // target phase would otherwise be unpredictable.\n document.addEventListener(\"submit\", this.#onSubmit, true);\n this.element.addEventListener(\"focusout\", this.#onFocusOut);\n this.element.addEventListener(\"input\", this.#onInput);\n this.element.addEventListener(\"change\", this.#onChange);\n }\n\n /** Tears down listeners and restores `novalidate` if we added it. */\n override disconnect(): void {\n document.removeEventListener(\"submit\", this.#onSubmit, true);\n this.element.removeEventListener(\"focusout\", this.#onFocusOut);\n this.element.removeEventListener(\"input\", this.#onInput);\n this.element.removeEventListener(\"change\", this.#onChange);\n if (this.element.hasAttribute(FormValidationController.#NOVALIDATE_MARKER)) {\n this.element.removeAttribute(\"novalidate\");\n this.element.removeAttribute(FormValidationController.#NOVALIDATE_MARKER);\n }\n }\n\n /**\n * Validates every control now, rendering or clearing each field's message, and\n * returns whether the whole form is valid. Marks every control touched so a\n * later input re-validates it. Bound via `data-action`\n * (`#validate`) or callable directly (e.g. before a programmatic submit).\n */\n validate(): boolean {\n return this.#validateAll().length === 0;\n }\n\n /**\n * Validates every control and returns one invalid control per field. Controls\n * are grouped by field first (see {@link #keyFor}) so a field with several\n * controls — a radio group, or a mirror plus its visible control — reflects\n * *all* of them: a valid sibling must never clear an invalid one's message.\n * Each group's first invalid control supplies the message and the focus target.\n */\n #validateAll(): ValidatableControl[] {\n const groups = new Map<unknown, FieldGroup>();\n for (const control of this.#controls) {\n this.#touched.add(control);\n const field = this.#fieldFor(control);\n const key = this.#keyFor(control, field);\n const group = groups.get(key);\n if (group) {\n group.controls.push(control);\n } else {\n groups.set(key, { field, controls: [control] });\n }\n }\n\n const invalid: ValidatableControl[] = [];\n for (const group of groups.values()) {\n const firstInvalid = this.#applyGroup(group);\n if (firstInvalid) invalid.push(firstInvalid);\n }\n return invalid;\n }\n\n /** Re-validates the whole field a single control belongs to (or that control). */\n #validateControl(control: ValidatableControl): void {\n const field = this.#fieldFor(control);\n const key = this.#keyFor(control, field);\n const controls = this.#controls.filter(\n (other) => this.#keyFor(other, this.#fieldFor(other)) === key,\n );\n this.#applyGroup({ field, controls });\n }\n\n /**\n * Runs native constraint validation across a field's controls and routes the\n * result to its `stimeo--form-field` outlet: the first invalid control's\n * `validationMessage` is shown, an all-valid field is cleared. Returns the\n * first invalid control (for the invalid list / focus), or `null` when valid.\n * Routing goes through the outlet, so the ARIA wiring is never duplicated here.\n */\n #applyGroup(group: FieldGroup): ValidatableControl | null {\n const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;\n if (group.field) {\n if (firstInvalid) {\n group.field.setError(firstInvalid.validationMessage);\n } else {\n group.field.clearError();\n }\n }\n return firstInvalid;\n }\n\n /**\n * A grouping key that collects controls belonging to the same field: the owning\n * `stimeo--form-field` when present, else a radio group's shared `name`, else\n * the control itself (always distinct).\n */\n #keyFor(control: ValidatableControl, field: FormFieldController | undefined): unknown {\n if (field) return field;\n if (control instanceof HTMLInputElement && control.type === \"radio\" && control.name) {\n return `radio:${control.name}`;\n }\n return control;\n }\n\n /**\n * Where focus should land for an invalid control. A visible control is focused\n * directly (status quo for native fields and radios). A validatable mirror\n * (the `hidden` attribute) cannot receive focus, so focus is delegated to the\n * visible widget: the owning field's `control` target when it is itself\n * focusable, else its first focusable descendant (e.g. a roving-tabindex\n * member). Resolved structurally — never by probing `focus()` — so behavior\n * is deterministic and CSS-independent.\n */\n #focusTargetFor(control: ValidatableControl): HTMLElement | null {\n if (!control.hidden) return control;\n const field = this.#fieldFor(control);\n if (!field?.hasControlTarget) return null;\n const root = field.controlTarget;\n if (root.matches(FOCUSABLE)) return root;\n return root.querySelector<HTMLElement>(FOCUSABLE);\n }\n\n /** The `stimeo--form-field` outlet whose element contains `control`, if any. */\n #fieldFor(control: ValidatableControl): FormFieldController | undefined {\n const elements = this.stimeoFormFieldOutletElements;\n for (let index = 0; index < elements.length; index++) {\n if (elements[index]?.contains(control)) return this.stimeoFormFieldOutlets[index];\n }\n return undefined;\n }\n\n /** This form's native controls that participate in constraint validation. */\n get #controls(): ValidatableControl[] {\n const controls: ValidatableControl[] = [];\n for (const element of Array.from(this.element.elements)) {\n if (this.#isValidatable(element)) controls.push(element);\n }\n return controls;\n }\n\n /** Narrows an event target to a validatable control. */\n #controlFrom(target: EventTarget | null): ValidatableControl | null {\n return target instanceof Element && this.#isValidatable(target) ? target : null;\n }\n\n #isValidatable(element: Element): element is ValidatableControl {\n return (\n (element instanceof HTMLInputElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement) &&\n // `willValidate` already excludes disabled, read-only, hidden, and button\n // controls — the exact set barred from constraint validation.\n element.willValidate\n );\n }\n}\n"]}
@@ -0,0 +1,58 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless `<turbo-frame>` loading-state behavior: while the frame is fetching it
5
+ * sets `aria-busy` + `data-frame-loading`, reveals an optional skeleton / overlay,
6
+ * suppresses interaction with the stale content, and retreats focus, restoring it on
7
+ * completion (no dedicated APG pattern; supports WCAG 2.2 **4.1.3 Status Messages**
8
+ * via `aria-busy` and 2.4.3 focus order via the retreat/restore).
9
+ *
10
+ * Markup contract (identifier: `stimeo--frame-loading`):
11
+ * <turbo-frame id="panel" data-controller="stimeo--frame-loading">
12
+ * <div data-stimeo--frame-loading-target="skeleton" hidden>…</div>
13
+ * <div data-stimeo--frame-loading-target="content">…</div>
14
+ * </turbo-frame>
15
+ *
16
+ * It subscribes on the frame to Turbo's own fetch lifecycle: `turbo:before-fetch-request`
17
+ * (which bubbles from the frame's links/forms or the frame itself) starts the loading
18
+ * state, and `turbo:frame-load` ends it (with `turbo:fetch-request-error` as a safety
19
+ * net so the state never sticks). `minDuration` keeps the skeleton up long enough to
20
+ * avoid a flicker.
21
+ *
22
+ * @remarks
23
+ * Behavior only — it ships no skeleton markup or styling (pair with Skeleton/CSS);
24
+ * loading is held purely in `aria-busy` / `data-frame-loading` and the optional
25
+ * targets' `hidden`. The `content` target is marked `inert` while loading to block
26
+ * double-submits, and focus inside the frame is explicitly blurred then restored
27
+ * (when `restoreFocus`) so it is testable without relying on emergent `inert`
28
+ * focus behavior. Listeners and the min-duration timer are torn down on
29
+ * `disconnect()` (Turbo navigation included), which also tidies the hooks so a
30
+ * cached frame is never left busy.
31
+ */
32
+ declare class FrameLoadingController extends Controller<HTMLElement> {
33
+ #private;
34
+ static targets: string[];
35
+ static values: {
36
+ minDuration: {
37
+ type: NumberConstructor;
38
+ default: number;
39
+ };
40
+ restoreFocus: {
41
+ type: BooleanConstructor;
42
+ default: boolean;
43
+ };
44
+ };
45
+ static events: readonly ["start", "end"];
46
+ readonly contentTarget: HTMLElement;
47
+ readonly skeletonTarget: HTMLElement;
48
+ readonly overlayTarget: HTMLElement;
49
+ readonly hasContentTarget: boolean;
50
+ readonly hasSkeletonTarget: boolean;
51
+ readonly hasOverlayTarget: boolean;
52
+ minDurationValue: number;
53
+ restoreFocusValue: boolean;
54
+ connect(): void;
55
+ disconnect(): void;
56
+ }
57
+
58
+ export { FrameLoadingController };
@@ -0,0 +1,177 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/frame_loading_controller.ts
4
+
5
+ // src/utils/safe_timeout.ts
6
+ var TimerRegistry = class {
7
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
8
+ ids = /* @__PURE__ */ new Set();
9
+ /**
10
+ * Cancels a single tracked timer.
11
+ *
12
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
13
+ * registry), so callers can clear defensively without guarding.
14
+ */
15
+ clear(id) {
16
+ if (this.ids.delete(id)) {
17
+ this.cancel(id);
18
+ }
19
+ }
20
+ /**
21
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
22
+ * to guarantee no timer outlives the element.
23
+ */
24
+ clearAll() {
25
+ for (const id of this.ids) {
26
+ this.cancel(id);
27
+ }
28
+ this.ids.clear();
29
+ }
30
+ /** Number of timers currently tracked (pending). */
31
+ get size() {
32
+ return this.ids.size;
33
+ }
34
+ };
35
+ var SafeTimeout = class extends TimerRegistry {
36
+ /**
37
+ * Schedules `callback` after `delay` ms and returns the timer id.
38
+ *
39
+ * The id is removed from the registry automatically when the timeout fires,
40
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
41
+ */
42
+ set(callback, delay) {
43
+ const id = this.schedule(() => {
44
+ this.ids.delete(id);
45
+ callback();
46
+ }, delay);
47
+ this.ids.add(id);
48
+ return id;
49
+ }
50
+ schedule(callback, delay) {
51
+ return window.setTimeout(callback, delay);
52
+ }
53
+ cancel(id) {
54
+ window.clearTimeout(id);
55
+ }
56
+ };
57
+
58
+ // src/controllers/frame_loading_controller.ts
59
+ var FrameLoadingController = class extends Controller {
60
+ static targets = ["content", "skeleton", "overlay"];
61
+ static values = {
62
+ minDuration: { type: Number, default: 0 },
63
+ restoreFocus: { type: Boolean, default: true }
64
+ };
65
+ static events = ["start", "end"];
66
+ #timeouts = new SafeTimeout();
67
+ #loading = false;
68
+ #startedAt = 0;
69
+ #inertApplied = false;
70
+ #previousFocus = null;
71
+ /** The id of the retreated element, used to re-find it if the load replaced it. */
72
+ #previousFocusId = "";
73
+ #onStart = () => {
74
+ this.#timeouts.clearAll();
75
+ if (!this.#loading) this.#begin();
76
+ };
77
+ #onEnd = () => {
78
+ if (!this.#loading) return;
79
+ const remaining = this.minDurationValue - (Date.now() - this.#startedAt);
80
+ if (remaining > 0) {
81
+ this.#timeouts.clearAll();
82
+ this.#timeouts.set(() => this.#finish(), remaining);
83
+ } else {
84
+ this.#finish();
85
+ }
86
+ };
87
+ connect() {
88
+ this.element.addEventListener("turbo:before-fetch-request", this.#onStart);
89
+ this.element.addEventListener("turbo:frame-load", this.#onEnd);
90
+ this.element.addEventListener("turbo:fetch-request-error", this.#onEnd);
91
+ }
92
+ disconnect() {
93
+ this.element.removeEventListener("turbo:before-fetch-request", this.#onStart);
94
+ this.element.removeEventListener("turbo:frame-load", this.#onEnd);
95
+ this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
96
+ this.#timeouts.clearAll();
97
+ if (this.#loading) {
98
+ this.element.removeAttribute("aria-busy");
99
+ this.element.removeAttribute("data-frame-loading");
100
+ this.#clearInert();
101
+ }
102
+ this.#loading = false;
103
+ this.#previousFocus = null;
104
+ }
105
+ /** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */
106
+ #begin() {
107
+ this.#loading = true;
108
+ this.#startedAt = Date.now();
109
+ this.element.setAttribute("aria-busy", "true");
110
+ this.element.setAttribute("data-frame-loading", "true");
111
+ if (this.hasSkeletonTarget) this.skeletonTarget.hidden = false;
112
+ if (this.hasOverlayTarget) this.overlayTarget.hidden = false;
113
+ this.#applyInert();
114
+ this.#retreatFocus();
115
+ this.dispatch("start", { detail: {} });
116
+ }
117
+ /** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */
118
+ #finish() {
119
+ this.#loading = false;
120
+ this.element.removeAttribute("aria-busy");
121
+ this.element.removeAttribute("data-frame-loading");
122
+ if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;
123
+ if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
124
+ this.#clearInert();
125
+ this.#restoreFocus();
126
+ this.dispatch("end", { detail: {} });
127
+ }
128
+ /** Marks the content inert to block double-submits while stale (if we own it). */
129
+ #applyInert() {
130
+ if (!this.hasContentTarget || this.contentTarget.hasAttribute("inert")) return;
131
+ this.contentTarget.setAttribute("inert", "");
132
+ this.#inertApplied = true;
133
+ }
134
+ #clearInert() {
135
+ if (!this.#inertApplied) return;
136
+ this.#inertApplied = false;
137
+ if (this.hasContentTarget) this.contentTarget.removeAttribute("inert");
138
+ }
139
+ /** Saves and blurs focus if it sits inside the frame about to go stale. */
140
+ #retreatFocus() {
141
+ this.#previousFocus = null;
142
+ this.#previousFocusId = "";
143
+ if (!this.restoreFocusValue) return;
144
+ const active = document.activeElement;
145
+ if (active instanceof HTMLElement && active !== this.element && this.element.contains(active)) {
146
+ this.#previousFocus = active;
147
+ this.#previousFocusId = active.id;
148
+ active.blur();
149
+ }
150
+ }
151
+ /**
152
+ * Restores focus after the load. The same node when it survived (e.g. a
153
+ * non-replacing update), else the element re-rendered with the same id inside the
154
+ * frame — Turbo frames typically re-emit the same controls. When neither is present
155
+ * (an anonymous control was replaced) focus is left where the browser put it, to
156
+ * avoid an unexpected jump (WCAG 3.2.x).
157
+ */
158
+ #restoreFocus() {
159
+ const target = this.#previousFocus;
160
+ const id = this.#previousFocusId;
161
+ this.#previousFocus = null;
162
+ this.#previousFocusId = "";
163
+ if (!this.restoreFocusValue) return;
164
+ if (target?.isConnected) {
165
+ target.focus();
166
+ return;
167
+ }
168
+ if (id) {
169
+ const replacement = document.getElementById(id);
170
+ if (replacement && this.element.contains(replacement)) replacement.focus();
171
+ }
172
+ }
173
+ };
174
+
175
+ export { FrameLoadingController };
176
+ //# sourceMappingURL=frame_loading_controller.js.map
177
+ //# sourceMappingURL=frame_loading_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/frame_loading_controller.ts"],"names":[],"mappings":"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","file":"frame_loading_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless `<turbo-frame>` loading-state behavior: while the frame is fetching it\n * sets `aria-busy` + `data-frame-loading`, reveals an optional skeleton / overlay,\n * suppresses interaction with the stale content, and retreats focus, restoring it on\n * completion (no dedicated APG pattern; supports WCAG 2.2 **4.1.3 Status Messages**\n * via `aria-busy` and 2.4.3 focus order via the retreat/restore).\n *\n * Markup contract (identifier: `stimeo--frame-loading`):\n * <turbo-frame id=\"panel\" data-controller=\"stimeo--frame-loading\">\n * <div data-stimeo--frame-loading-target=\"skeleton\" hidden>…</div>\n * <div data-stimeo--frame-loading-target=\"content\">…</div>\n * </turbo-frame>\n *\n * It subscribes on the frame to Turbo's own fetch lifecycle: `turbo:before-fetch-request`\n * (which bubbles from the frame's links/forms or the frame itself) starts the loading\n * state, and `turbo:frame-load` ends it (with `turbo:fetch-request-error` as a safety\n * net so the state never sticks). `minDuration` keeps the skeleton up long enough to\n * avoid a flicker.\n *\n * @remarks\n * Behavior only — it ships no skeleton markup or styling (pair with Skeleton/CSS);\n * loading is held purely in `aria-busy` / `data-frame-loading` and the optional\n * targets' `hidden`. The `content` target is marked `inert` while loading to block\n * double-submits, and focus inside the frame is explicitly blurred then restored\n * (when `restoreFocus`) so it is testable without relying on emergent `inert`\n * focus behavior. Listeners and the min-duration timer are torn down on\n * `disconnect()` (Turbo navigation included), which also tidies the hooks so a\n * cached frame is never left busy.\n */\nexport class FrameLoadingController extends Controller<HTMLElement> {\n static override targets = [\"content\", \"skeleton\", \"overlay\"];\n static override values = {\n minDuration: { type: Number, default: 0 },\n restoreFocus: { type: Boolean, default: true },\n };\n static events = [\"start\", \"end\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly skeletonTarget: HTMLElement;\n declare readonly overlayTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n declare readonly hasSkeletonTarget: boolean;\n declare readonly hasOverlayTarget: boolean;\n\n declare minDurationValue: number;\n declare restoreFocusValue: boolean;\n\n readonly #timeouts = new SafeTimeout();\n #loading = false;\n #startedAt = 0;\n #inertApplied = false;\n #previousFocus: HTMLElement | null = null;\n /** The id of the retreated element, used to re-find it if the load replaced it. */\n #previousFocusId = \"\";\n\n readonly #onStart = (): void => {\n // A (possibly new) fetch began: cancel any pending min-duration finish so the\n // loading state is not torn down mid-load, then begin if not already loading.\n this.#timeouts.clearAll();\n if (!this.#loading) this.#begin();\n };\n\n readonly #onEnd = (): void => {\n if (!this.#loading) return;\n const remaining = this.minDurationValue - (Date.now() - this.#startedAt);\n if (remaining > 0) {\n this.#timeouts.clearAll();\n this.#timeouts.set(() => this.#finish(), remaining);\n } else {\n this.#finish();\n }\n };\n\n override connect(): void {\n this.element.addEventListener(\"turbo:before-fetch-request\", this.#onStart);\n this.element.addEventListener(\"turbo:frame-load\", this.#onEnd);\n this.element.addEventListener(\"turbo:fetch-request-error\", this.#onEnd);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"turbo:before-fetch-request\", this.#onStart);\n this.element.removeEventListener(\"turbo:frame-load\", this.#onEnd);\n this.element.removeEventListener(\"turbo:fetch-request-error\", this.#onEnd);\n this.#timeouts.clearAll();\n // Tidy the hooks so a cached frame is not restored mid-loading (no focus move —\n // the element is leaving the DOM).\n if (this.#loading) {\n this.element.removeAttribute(\"aria-busy\");\n this.element.removeAttribute(\"data-frame-loading\");\n this.#clearInert();\n }\n this.#loading = false;\n this.#previousFocus = null;\n }\n\n /** Enters the loading state: hooks, skeleton/overlay, inert content, focus retreat. */\n #begin(): void {\n this.#loading = true;\n this.#startedAt = Date.now();\n this.element.setAttribute(\"aria-busy\", \"true\");\n this.element.setAttribute(\"data-frame-loading\", \"true\");\n if (this.hasSkeletonTarget) this.skeletonTarget.hidden = false;\n if (this.hasOverlayTarget) this.overlayTarget.hidden = false;\n this.#applyInert();\n this.#retreatFocus();\n this.dispatch(\"start\", { detail: {} });\n }\n\n /** Leaves the loading state: restore hooks, hide skeleton/overlay, restore focus. */\n #finish(): void {\n this.#loading = false;\n this.element.removeAttribute(\"aria-busy\");\n this.element.removeAttribute(\"data-frame-loading\");\n if (this.hasSkeletonTarget) this.skeletonTarget.hidden = true;\n if (this.hasOverlayTarget) this.overlayTarget.hidden = true;\n this.#clearInert();\n this.#restoreFocus();\n this.dispatch(\"end\", { detail: {} });\n }\n\n /** Marks the content inert to block double-submits while stale (if we own it). */\n #applyInert(): void {\n if (!this.hasContentTarget || this.contentTarget.hasAttribute(\"inert\")) return;\n this.contentTarget.setAttribute(\"inert\", \"\");\n this.#inertApplied = true;\n }\n\n #clearInert(): void {\n if (!this.#inertApplied) return;\n this.#inertApplied = false;\n if (this.hasContentTarget) this.contentTarget.removeAttribute(\"inert\");\n }\n\n /** Saves and blurs focus if it sits inside the frame about to go stale. */\n #retreatFocus(): void {\n this.#previousFocus = null;\n this.#previousFocusId = \"\";\n if (!this.restoreFocusValue) return;\n const active = document.activeElement;\n if (active instanceof HTMLElement && active !== this.element && this.element.contains(active)) {\n this.#previousFocus = active;\n this.#previousFocusId = active.id;\n active.blur();\n }\n }\n\n /**\n * Restores focus after the load. The same node when it survived (e.g. a\n * non-replacing update), else the element re-rendered with the same id inside the\n * frame — Turbo frames typically re-emit the same controls. When neither is present\n * (an anonymous control was replaced) focus is left where the browser put it, to\n * avoid an unexpected jump (WCAG 3.2.x).\n */\n #restoreFocus(): void {\n const target = this.#previousFocus;\n const id = this.#previousFocusId;\n this.#previousFocus = null;\n this.#previousFocusId = \"\";\n if (!this.restoreFocusValue) return;\n if (target?.isConnected) {\n target.focus();\n return;\n }\n if (id) {\n const replacement = document.getElementById(id);\n if (replacement && this.element.contains(replacement)) replacement.focus();\n }\n }\n}\n"]}
@@ -0,0 +1,50 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless "highlight on insert" behavior: briefly flags a freshly inserted element
5
+ * with `data-highlight` so CSS can flash / fade it in, then removes the flag after
6
+ * `duration` ms (no dedicated APG pattern; a purely visual emphasis that honors the
7
+ * WCAG "animation from interactions" practice via `prefers-reduced-motion`).
8
+ *
9
+ * Markup contract (identifier: `stimeo--highlight`):
10
+ * <!-- self-highlight: put it on the inserted row itself -->
11
+ * <li data-controller="stimeo--highlight">New item</li>
12
+ *
13
+ * <!-- container mode: watch for added children and highlight each -->
14
+ * <ul data-controller="stimeo--highlight" data-stimeo--highlight-observe-value="true"></ul>
15
+ *
16
+ * In the default (self) mode the controller element is highlighted once on
17
+ * `connect()`. With `observe`, a `MutationObserver` instead highlights every element
18
+ * child added to the container (e.g. Turbo Stream appends). Each highlight sets
19
+ * `data-highlight="true"`, dispatches `start`, and after `duration` removes it and
20
+ * dispatches `end` — both carry `detail.element`.
21
+ *
22
+ * @remarks
23
+ * Behavior only — it ships no color or animation, just the hook (pair with CSS, and
24
+ * with Announcer / Flash for non-visual notice). Under `prefers-reduced-motion: reduce`
25
+ * the emphasis is suppressed entirely (the element simply appears), so no hook or
26
+ * event is emitted. The self hook is added on `connect()` and removed before its
27
+ * `duration` elapses, so it does not linger into a `turbo:before-cache` snapshot; the
28
+ * observer and pending timers are torn down on `disconnect()` (Turbo navigation
29
+ * included).
30
+ */
31
+ declare class HighlightController extends Controller<HTMLElement> {
32
+ #private;
33
+ static values: {
34
+ duration: {
35
+ type: NumberConstructor;
36
+ default: number;
37
+ };
38
+ observe: {
39
+ type: BooleanConstructor;
40
+ default: boolean;
41
+ };
42
+ };
43
+ static events: readonly ["start", "end"];
44
+ durationValue: number;
45
+ observeValue: boolean;
46
+ connect(): void;
47
+ disconnect(): void;
48
+ }
49
+
50
+ export { HighlightController };