stimeo-ui 0.5.0 → 0.7.0

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 (230) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/dist/controllers/alert_dialog_controller.d.ts +2 -0
  3. package/dist/controllers/alert_dialog_controller.js +75 -4
  4. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  5. package/dist/controllers/aspect_ratio_controller.d.ts +1 -3
  6. package/dist/controllers/aspect_ratio_controller.js +19 -11
  7. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  8. package/dist/controllers/auto_submit_controller.d.ts +2 -0
  9. package/dist/controllers/auto_submit_controller.js.map +1 -1
  10. package/dist/controllers/avatar_controller.d.ts +36 -15
  11. package/dist/controllers/avatar_controller.js +237 -40
  12. package/dist/controllers/avatar_controller.js.map +1 -1
  13. package/dist/controllers/breadcrumb_controller.d.ts +2 -0
  14. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  15. package/dist/controllers/bulk_select_controller.d.ts +2 -0
  16. package/dist/controllers/bulk_select_controller.js.map +1 -1
  17. package/dist/controllers/calendar_controller.d.ts +2 -0
  18. package/dist/controllers/calendar_controller.js.map +1 -1
  19. package/dist/controllers/carousel_controller.d.ts +12 -3
  20. package/dist/controllers/carousel_controller.js +85 -9
  21. package/dist/controllers/carousel_controller.js.map +1 -1
  22. package/dist/controllers/character_counter_controller.d.ts +52 -20
  23. package/dist/controllers/character_counter_controller.js +338 -63
  24. package/dist/controllers/character_counter_controller.js.map +1 -1
  25. package/dist/controllers/checkbox_controller.d.ts +34 -5
  26. package/dist/controllers/checkbox_controller.js +136 -25
  27. package/dist/controllers/checkbox_controller.js.map +1 -1
  28. package/dist/controllers/color_picker_controller.d.ts +10 -1
  29. package/dist/controllers/color_picker_controller.js +35 -9
  30. package/dist/controllers/color_picker_controller.js.map +1 -1
  31. package/dist/controllers/combobox_controller.d.ts +2 -0
  32. package/dist/controllers/combobox_controller.js.map +1 -1
  33. package/dist/controllers/command_palette_controller.d.ts +2 -0
  34. package/dist/controllers/command_palette_controller.js +75 -4
  35. package/dist/controllers/command_palette_controller.js.map +1 -1
  36. package/dist/controllers/conditional_fields_controller.d.ts +42 -14
  37. package/dist/controllers/conditional_fields_controller.js +345 -51
  38. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  39. package/dist/controllers/confirm_controller.d.ts +2 -0
  40. package/dist/controllers/confirm_controller.js +75 -4
  41. package/dist/controllers/confirm_controller.js.map +1 -1
  42. package/dist/controllers/count_up_controller.d.ts +2 -0
  43. package/dist/controllers/count_up_controller.js.map +1 -1
  44. package/dist/controllers/countdown_controller.d.ts +4 -0
  45. package/dist/controllers/countdown_controller.js.map +1 -1
  46. package/dist/controllers/currency_input_controller.d.ts +2 -0
  47. package/dist/controllers/currency_input_controller.js.map +1 -1
  48. package/dist/controllers/data_grid_controller.d.ts +3 -0
  49. package/dist/controllers/data_grid_controller.js.map +1 -1
  50. package/dist/controllers/date_range_picker_controller.d.ts +23 -4
  51. package/dist/controllers/date_range_picker_controller.js +157 -30
  52. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  53. package/dist/controllers/dialog_controller.js +75 -4
  54. package/dist/controllers/dialog_controller.js.map +1 -1
  55. package/dist/controllers/direct_upload_controller.d.ts +56 -24
  56. package/dist/controllers/direct_upload_controller.js +201 -45
  57. package/dist/controllers/direct_upload_controller.js.map +1 -1
  58. package/dist/controllers/dirty_form_controller.d.ts +14 -6
  59. package/dist/controllers/dirty_form_controller.js +192 -29
  60. package/dist/controllers/dirty_form_controller.js.map +1 -1
  61. package/dist/controllers/dismissible_controller.d.ts +2 -0
  62. package/dist/controllers/dismissible_controller.js +83 -18
  63. package/dist/controllers/dismissible_controller.js.map +1 -1
  64. package/dist/controllers/drawer_controller.js +75 -4
  65. package/dist/controllers/drawer_controller.js.map +1 -1
  66. package/dist/controllers/empty_state_controller.d.ts +2 -0
  67. package/dist/controllers/empty_state_controller.js.map +1 -1
  68. package/dist/controllers/file_dropzone_controller.d.ts +9 -1
  69. package/dist/controllers/file_dropzone_controller.js +26 -3
  70. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  71. package/dist/controllers/filter_controller.d.ts +2 -0
  72. package/dist/controllers/filter_controller.js.map +1 -1
  73. package/dist/controllers/flash_controller.d.ts +4 -0
  74. package/dist/controllers/flash_controller.js.map +1 -1
  75. package/dist/controllers/focus_controller.d.ts +4 -3
  76. package/dist/controllers/focus_controller.js +75 -4
  77. package/dist/controllers/focus_controller.js.map +1 -1
  78. package/dist/controllers/form_field_controller.d.ts +50 -10
  79. package/dist/controllers/form_field_controller.js +280 -62
  80. package/dist/controllers/form_field_controller.js.map +1 -1
  81. package/dist/controllers/form_validation_controller.d.ts +10 -8
  82. package/dist/controllers/form_validation_controller.js +208 -83
  83. package/dist/controllers/form_validation_controller.js.map +1 -1
  84. package/dist/controllers/frame_loading_controller.d.ts +2 -0
  85. package/dist/controllers/frame_loading_controller.js.map +1 -1
  86. package/dist/controllers/highlight_controller.d.ts +2 -0
  87. package/dist/controllers/highlight_controller.js.map +1 -1
  88. package/dist/controllers/hover_card_controller.d.ts +2 -2
  89. package/dist/controllers/hover_card_controller.js.map +1 -1
  90. package/dist/controllers/idle_controller.d.ts +5 -3
  91. package/dist/controllers/idle_controller.js +27 -5
  92. package/dist/controllers/idle_controller.js.map +1 -1
  93. package/dist/controllers/input_mask_controller.d.ts +2 -0
  94. package/dist/controllers/input_mask_controller.js.map +1 -1
  95. package/dist/controllers/lazy_frame_controller.d.ts +2 -0
  96. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  97. package/dist/controllers/listbox_controller.d.ts +2 -0
  98. package/dist/controllers/listbox_controller.js.map +1 -1
  99. package/dist/controllers/local_time_controller.d.ts +2 -0
  100. package/dist/controllers/local_time_controller.js.map +1 -1
  101. package/dist/controllers/masonry_controller.d.ts +2 -0
  102. package/dist/controllers/masonry_controller.js.map +1 -1
  103. package/dist/controllers/menubar_controller.js +5 -3
  104. package/dist/controllers/menubar_controller.js.map +1 -1
  105. package/dist/controllers/meter_controller.d.ts +2 -0
  106. package/dist/controllers/meter_controller.js.map +1 -1
  107. package/dist/controllers/multi_select_controller.d.ts +48 -12
  108. package/dist/controllers/multi_select_controller.js +460 -151
  109. package/dist/controllers/multi_select_controller.js.map +1 -1
  110. package/dist/controllers/nested_form_controller.d.ts +2 -0
  111. package/dist/controllers/nested_form_controller.js.map +1 -1
  112. package/dist/controllers/network_status_controller.d.ts +2 -0
  113. package/dist/controllers/network_status_controller.js.map +1 -1
  114. package/dist/controllers/number_input_controller.d.ts +26 -6
  115. package/dist/controllers/number_input_controller.js +317 -51
  116. package/dist/controllers/number_input_controller.js.map +1 -1
  117. package/dist/controllers/otp_controller.d.ts +3 -0
  118. package/dist/controllers/otp_controller.js.map +1 -1
  119. package/dist/controllers/overflow_indicator_controller.d.ts +2 -0
  120. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  121. package/dist/controllers/overflow_menu_controller.d.ts +2 -0
  122. package/dist/controllers/overflow_menu_controller.js +6 -1
  123. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  124. package/dist/controllers/pagination_controller.d.ts +2 -0
  125. package/dist/controllers/pagination_controller.js +35 -1
  126. package/dist/controllers/pagination_controller.js.map +1 -1
  127. package/dist/controllers/password_reveal_controller.d.ts +2 -0
  128. package/dist/controllers/password_reveal_controller.js.map +1 -1
  129. package/dist/controllers/password_strength_controller.d.ts +5 -3
  130. package/dist/controllers/password_strength_controller.js +20 -2
  131. package/dist/controllers/password_strength_controller.js.map +1 -1
  132. package/dist/controllers/persist_controller.d.ts +33 -19
  133. package/dist/controllers/persist_controller.js +449 -120
  134. package/dist/controllers/persist_controller.js.map +1 -1
  135. package/dist/controllers/pointer_drag_controller.d.ts +3 -0
  136. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  137. package/dist/controllers/popover_controller.d.ts +2 -2
  138. package/dist/controllers/popover_controller.js +77 -4
  139. package/dist/controllers/popover_controller.js.map +1 -1
  140. package/dist/controllers/portal_controller.d.ts +3 -2
  141. package/dist/controllers/portal_controller.js.map +1 -1
  142. package/dist/controllers/preview_guard_controller.d.ts +2 -0
  143. package/dist/controllers/preview_guard_controller.js.map +1 -1
  144. package/dist/controllers/progress_controller.d.ts +3 -0
  145. package/dist/controllers/progress_controller.js.map +1 -1
  146. package/dist/controllers/radio_group_controller.d.ts +44 -15
  147. package/dist/controllers/radio_group_controller.js +540 -56
  148. package/dist/controllers/radio_group_controller.js.map +1 -1
  149. package/dist/controllers/rating_controller.d.ts +38 -31
  150. package/dist/controllers/rating_controller.js +276 -89
  151. package/dist/controllers/rating_controller.js.map +1 -1
  152. package/dist/controllers/reading_progress_controller.d.ts +2 -0
  153. package/dist/controllers/reading_progress_controller.js.map +1 -1
  154. package/dist/controllers/resizable_controller.d.ts +2 -0
  155. package/dist/controllers/resizable_controller.js +33 -0
  156. package/dist/controllers/resizable_controller.js.map +1 -1
  157. package/dist/controllers/roving_controller.d.ts +6 -0
  158. package/dist/controllers/roving_controller.js +60 -5
  159. package/dist/controllers/roving_controller.js.map +1 -1
  160. package/dist/controllers/scroll_area_controller.d.ts +25 -11
  161. package/dist/controllers/scroll_area_controller.js +557 -125
  162. package/dist/controllers/scroll_area_controller.js.map +1 -1
  163. package/dist/controllers/scroll_visibility_controller.d.ts +2 -0
  164. package/dist/controllers/scroll_visibility_controller.js +33 -0
  165. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  166. package/dist/controllers/scrollspy_controller.d.ts +2 -0
  167. package/dist/controllers/scrollspy_controller.js.map +1 -1
  168. package/dist/controllers/separator_controller.d.ts +54 -11
  169. package/dist/controllers/separator_controller.js +354 -38
  170. package/dist/controllers/separator_controller.js.map +1 -1
  171. package/dist/controllers/sidebar_controller.js +83 -10
  172. package/dist/controllers/sidebar_controller.js.map +1 -1
  173. package/dist/controllers/skeleton_controller.d.ts +2 -0
  174. package/dist/controllers/skeleton_controller.js.map +1 -1
  175. package/dist/controllers/slider_controller.d.ts +2 -0
  176. package/dist/controllers/slider_controller.js.map +1 -1
  177. package/dist/controllers/smart_sticky_header_controller.d.ts +2 -0
  178. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  179. package/dist/controllers/spinner_controller.d.ts +2 -0
  180. package/dist/controllers/spinner_controller.js.map +1 -1
  181. package/dist/controllers/step_indicator_controller.d.ts +2 -0
  182. package/dist/controllers/step_indicator_controller.js.map +1 -1
  183. package/dist/controllers/stepper_controller.d.ts +2 -0
  184. package/dist/controllers/stepper_controller.js.map +1 -1
  185. package/dist/controllers/stick_to_bottom_controller.d.ts +2 -0
  186. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  187. package/dist/controllers/sticky_observer_controller.d.ts +2 -0
  188. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  189. package/dist/controllers/submit_once_controller.d.ts +94 -38
  190. package/dist/controllers/submit_once_controller.js +399 -121
  191. package/dist/controllers/submit_once_controller.js.map +1 -1
  192. package/dist/controllers/switch_controller.d.ts +2 -0
  193. package/dist/controllers/switch_controller.js.map +1 -1
  194. package/dist/controllers/tags_input_controller.d.ts +43 -11
  195. package/dist/controllers/tags_input_controller.js +356 -120
  196. package/dist/controllers/tags_input_controller.js.map +1 -1
  197. package/dist/controllers/textarea_autosize_controller.d.ts +2 -0
  198. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  199. package/dist/controllers/theme_controller.d.ts +2 -0
  200. package/dist/controllers/theme_controller.js +8 -6
  201. package/dist/controllers/theme_controller.js.map +1 -1
  202. package/dist/controllers/time_picker_controller.d.ts +44 -13
  203. package/dist/controllers/time_picker_controller.js +296 -107
  204. package/dist/controllers/time_picker_controller.js.map +1 -1
  205. package/dist/controllers/toast_controller.d.ts +4 -0
  206. package/dist/controllers/toast_controller.js.map +1 -1
  207. package/dist/controllers/toggle_group_controller.d.ts +41 -13
  208. package/dist/controllers/toggle_group_controller.js +378 -55
  209. package/dist/controllers/toggle_group_controller.js.map +1 -1
  210. package/dist/controllers/toolbar_controller.js +5 -3
  211. package/dist/controllers/toolbar_controller.js.map +1 -1
  212. package/dist/controllers/tooltip_controller.d.ts +2 -2
  213. package/dist/controllers/tooltip_controller.js.map +1 -1
  214. package/dist/controllers/transition_controller.d.ts +4 -3
  215. package/dist/controllers/transition_controller.js.map +1 -1
  216. package/dist/controllers/tree_view_controller.d.ts +2 -0
  217. package/dist/controllers/tree_view_controller.js +7 -4
  218. package/dist/controllers/tree_view_controller.js.map +1 -1
  219. package/dist/index.js +4963 -1581
  220. package/dist/index.js.map +1 -1
  221. package/dist/inspector/cli.d.ts +123 -6
  222. package/dist/inspector/cli.js +212 -18
  223. package/dist/inspector/cli.js.map +1 -1
  224. package/dist/inspector/cli_bin.js +274 -50
  225. package/dist/inspector/cli_bin.js.map +1 -1
  226. package/dist/inspector/examples.json +15 -15
  227. package/dist/inspector/manifest.json +602 -80
  228. package/dist/positioning/index.d.ts +4 -2
  229. package/dist/positioning/index.js.map +1 -1
  230. package/package.json +1 -1
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/form_field_controller.ts
4
4
 
5
+ // src/utils/announce.ts
6
+ function announce(message, options = {}) {
7
+ const text = message.trim();
8
+ if (text.length === 0) return;
9
+ window.dispatchEvent(
10
+ new CustomEvent("stimeo--announcer:announce", {
11
+ detail: { message: text, assertive: options.assertive === true }
12
+ })
13
+ );
14
+ }
15
+
5
16
  // src/utils/aria_ids.ts
6
17
  var counter = 0;
7
18
  function uniqueId(prefix = "stimeo") {
@@ -19,7 +30,111 @@ function ensureId(element, prefix = "stimeo") {
19
30
  return id;
20
31
  }
21
32
 
33
+ // src/utils/attribute_lease.ts
34
+ var AttributeLease = class {
35
+ #attribute;
36
+ #records = /* @__PURE__ */ new Map();
37
+ /** @param attribute - The attribute whose temporary values this lease owns. */
38
+ constructor(attribute) {
39
+ this.#attribute = attribute;
40
+ }
41
+ /** Writes or removes the leased attribute while preserving its authored value. */
42
+ write(element, value) {
43
+ const existing = this.#records.get(element);
44
+ if (existing) {
45
+ existing.written = value;
46
+ } else {
47
+ this.#records.set(element, {
48
+ original: element.getAttribute(this.#attribute),
49
+ written: value
50
+ });
51
+ }
52
+ this.#reflect(element, value);
53
+ }
54
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
55
+ return(element) {
56
+ const record = this.#records.get(element);
57
+ if (!record) return;
58
+ this.#records.delete(element);
59
+ const stillOwned = element.getAttribute(this.#attribute) === record.written;
60
+ if (stillOwned) this.#reflect(element, record.original);
61
+ }
62
+ /** Reflects only a real value transition, avoiding self-triggered mutation work. */
63
+ #reflect(element, value) {
64
+ if (element.getAttribute(this.#attribute) === value) return;
65
+ if (value === null) element.removeAttribute(this.#attribute);
66
+ else element.setAttribute(this.#attribute, value);
67
+ }
68
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
69
+ returnAll() {
70
+ for (const element of Array.from(this.#records.keys())) this.return(element);
71
+ }
72
+ };
73
+
74
+ // src/utils/before_cache_reset.ts
75
+ var BeforeCacheReset = class _BeforeCacheReset {
76
+ /** Every subscribed instance, iterated by the one shared document listener. */
77
+ static #subscribers = /* @__PURE__ */ new Set();
78
+ /** The shared listener; installed while at least one instance is subscribed. */
79
+ static #onBeforeCache = () => {
80
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
81
+ };
82
+ #rewind;
83
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
84
+ constructor(rewind) {
85
+ this.#rewind = rewind;
86
+ }
87
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
88
+ activate() {
89
+ const first = _BeforeCacheReset.#subscribers.size === 0;
90
+ _BeforeCacheReset.#subscribers.add(this);
91
+ if (first) {
92
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
93
+ }
94
+ }
95
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
96
+ deactivate() {
97
+ _BeforeCacheReset.#subscribers.delete(this);
98
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
99
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
100
+ }
101
+ };
102
+
103
+ // src/utils/microtask_coalescer.ts
104
+ var MicrotaskCoalescer = class {
105
+ #run;
106
+ #queued = false;
107
+ #active = false;
108
+ #generation = 0;
109
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
110
+ constructor(run) {
111
+ this.#run = run;
112
+ }
113
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
114
+ activate() {
115
+ this.#active = true;
116
+ }
117
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
118
+ cancel() {
119
+ this.#active = false;
120
+ this.#queued = false;
121
+ this.#generation += 1;
122
+ }
123
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
124
+ schedule() {
125
+ if (!this.#active || this.#queued) return;
126
+ this.#queued = true;
127
+ const generation = this.#generation;
128
+ queueMicrotask(() => {
129
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
130
+ this.#queued = false;
131
+ this.#run();
132
+ });
133
+ }
134
+ };
135
+
22
136
  // src/controllers/form_field_controller.ts
137
+ var OBSERVED_ATTRIBUTES = ["hidden", "id"];
23
138
  var FormFieldController = class _FormFieldController extends Controller {
24
139
  static targets = ["control", "description", "error"];
25
140
  static values = {
@@ -29,31 +144,85 @@ var FormFieldController = class _FormFieldController extends Controller {
29
144
  static events = ["validate"];
30
145
  /** Root attribute (CSS hook) reflecting the invalid state. */
31
146
  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
- */
147
+ /** Collapses one target/morph batch into one silent ARIA reconciliation. */
148
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
149
+ /** ARIA ownership is scoped to the current singular control target. */
150
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
151
+ #ariaDescribedBy = new AttributeLease("aria-describedby");
152
+ #ariaErrorMessage = new AttributeLease("aria-errormessage");
153
+ #ariaInvalid = new AttributeLease("aria-invalid");
154
+ /** Watches retained target ids, error visibility, and error content. */
155
+ #observer = new MutationObserver((records) => {
156
+ if (records.some((record) => this.#isRelevantMutation(record))) {
157
+ this.#reconcile.schedule();
158
+ }
159
+ });
160
+ #activeControl = null;
36
161
  #baseDescribedBy = [];
37
- /** Wires ids, captures consumer tokens, and reflects any initial error state. */
162
+ #explicitInvalid = false;
163
+ #initialized = false;
164
+ /** Wires the initial graph and starts retained-target reconciliation. */
38
165
  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");
166
+ this.#reconcile.activate();
167
+ this.#ensureAssociationIds();
168
+ this.#beforeCache.activate();
169
+ if (!this.#initialized) {
170
+ this.#explicitInvalid = this.element.hasAttribute(_FormFieldController.#INVALID_ATTR) && this.#shownErrors().length === 0;
171
+ this.#initialized = true;
44
172
  }
45
- this.#baseDescribedBy = this.#externalDescribedByTokens();
46
- this.#reflect();
173
+ this.#reconcileDom();
174
+ this.#observer.observe(this.element, {
175
+ attributes: true,
176
+ attributeFilter: OBSERVED_ATTRIBUTES,
177
+ characterData: true,
178
+ childList: true,
179
+ subtree: true
180
+ });
181
+ }
182
+ /** Releases observers, queued work, and ARIA borrowed on the current control. */
183
+ disconnect() {
184
+ this.#reconcile.cancel();
185
+ this.#observer.disconnect();
186
+ this.#beforeCache.deactivate();
187
+ this.#activeControl && this.#releaseControl(this.#activeControl);
188
+ }
189
+ /** Reconciles a control inserted or replaced at runtime. */
190
+ controlTargetConnected() {
191
+ this.#reconcile.schedule();
192
+ }
193
+ /** Schedules restoration of authored ARIA when a control leaves this field. */
194
+ controlTargetDisconnected() {
195
+ this.#reconcile.schedule();
196
+ }
197
+ /** Reconciles a description inserted or replaced at runtime. */
198
+ descriptionTargetConnected() {
199
+ this.#reconcile.schedule();
200
+ }
201
+ /** Removes a departed description from the control's association graph. */
202
+ descriptionTargetDisconnected() {
203
+ this.#reconcile.schedule();
204
+ }
205
+ /** Reconciles an error region inserted or replaced at runtime. */
206
+ errorTargetConnected() {
207
+ this.#reconcile.schedule();
208
+ }
209
+ /** Removes a departed error from invalid state and the association graph. */
210
+ errorTargetDisconnected() {
211
+ this.#reconcile.schedule();
47
212
  }
48
213
  /**
49
214
  * Marks the field invalid and shows the error message. Bound via `data-action`
50
215
  * (`#setError`) or callable directly.
51
216
  *
217
+ * A non-empty shown message is announced exactly once through the shared
218
+ * assertive announcer. Initial/server reconciliation is deliberately silent.
219
+ *
52
220
  * @param arg - Either the message string, or the action event whose
53
221
  * `data-stimeo--form-field-message-param` supplies it. When no message is
54
222
  * resolvable, any already-populated error targets are simply (re)shown.
223
+ * @param options - Programmatic overrides. Stimulus actions use the Values.
55
224
  */
56
- setError(arg) {
225
+ setError(arg, options = {}) {
57
226
  const message = this.#resolveMessage(arg);
58
227
  if (message !== null && this.hasErrorTarget) {
59
228
  this.errorTargets[0]?.replaceChildren(document.createTextNode(message));
@@ -61,70 +230,86 @@ var FormFieldController = class _FormFieldController extends Controller {
61
230
  for (const error of this.errorTargets) {
62
231
  error.hidden = (error.textContent ?? "").trim() === "";
63
232
  }
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
- }
233
+ this.#explicitInvalid = true;
234
+ this.#reconcileDom();
235
+ const shownMessage = this.#shownMessage();
236
+ const detail = { valid: false, message: shownMessage };
237
+ this.dispatch("validate", { detail });
238
+ announce(shownMessage, { assertive: true });
239
+ if (options.focus ?? this.focusOnErrorValue) this.#activeControl?.focus();
69
240
  }
70
241
  /**
71
242
  * Clears the error: empties and hides every error target and marks the field
72
243
  * valid. Bound via `data-action` (`#clearError`) or callable directly.
244
+ *
245
+ * Clearing is silent: the visual/ARIA state and validation event are sufficient,
246
+ * while success wording remains an explicit consumer announcement.
73
247
  */
74
248
  clearError() {
75
249
  for (const error of this.errorTargets) {
76
250
  error.replaceChildren();
77
251
  error.hidden = true;
78
252
  }
79
- this.#reflect();
80
- this.dispatch("validate", { detail: { valid: true, message: "" } });
253
+ this.#explicitInvalid = false;
254
+ this.#reconcileDom();
255
+ const detail = { valid: true, message: "" };
256
+ this.dispatch("validate", { detail });
81
257
  }
82
258
  /**
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`.
259
+ * Rebuilds ids and derived ARIA from the settled target graph without reporting
260
+ * a user validation action.
91
261
  */
92
- #reflect(force = false) {
262
+ #reconcileDom() {
263
+ this.#ensureAssociationIds();
264
+ this.#adoptCurrentControl();
93
265
  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);
266
+ const invalid = this.#explicitInvalid || shown.length > 0;
267
+ this.element.toggleAttribute(_FormFieldController.#INVALID_ATTR, invalid);
268
+ const control = this.#activeControl;
269
+ if (!control) return;
270
+ this.#ariaInvalid.write(control, invalid ? "true" : "false");
271
+ const primaryErrorId = shown[0]?.id ?? null;
272
+ this.#ariaErrorMessage.write(control, primaryErrorId);
273
+ const associationIds = this.#orderedElements([...this.descriptionTargets, ...shown]).map(
274
+ (element) => element.id
275
+ );
276
+ const describedBy = this.#uniqueTokens([...this.#baseDescribedBy, ...associationIds]);
277
+ this.#ariaDescribedBy.write(control, describedBy.length > 0 ? describedBy.join(" ") : null);
278
+ }
279
+ /** Assigns stable ids before either capture or reflection uses them. */
280
+ #ensureAssociationIds() {
281
+ for (const description of this.descriptionTargets) {
282
+ ensureId(description, "stimeo--form-field-desc");
99
283
  }
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");
284
+ for (const error of this.errorTargets) {
285
+ ensureId(error, "stimeo--form-field-error");
109
286
  }
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");
287
+ }
288
+ /** Switches ARIA ownership when the singular current control target changes. */
289
+ #adoptCurrentControl() {
290
+ const next = this.hasControlTarget ? this.controlTarget : null;
291
+ if (next === this.#activeControl) return;
292
+ this.#activeControl && this.#releaseControl(this.#activeControl);
293
+ this.#activeControl = next;
294
+ this.#baseDescribedBy = next ? this.#externalDescribedByTokens(next) : [];
295
+ }
296
+ /** Restores one departed control without overwriting later consumer edits. */
297
+ #releaseControl(control) {
298
+ this.#ariaDescribedBy.return(control);
299
+ this.#ariaErrorMessage.return(control);
300
+ this.#ariaInvalid.return(control);
301
+ if (control === this.#activeControl) {
302
+ this.#activeControl = null;
303
+ this.#baseDescribedBy = [];
119
304
  }
120
305
  }
121
- /** Error targets currently visible and non-empty. */
306
+ /** Error targets currently visible and non-empty, in document order. */
122
307
  #shownErrors() {
123
- return this.errorTargets.filter(
124
- (error) => !error.hidden && (error.textContent ?? "").trim() !== ""
308
+ return this.#orderedElements(
309
+ this.errorTargets.filter((error) => !error.hidden && (error.textContent ?? "").trim() !== "")
125
310
  );
126
311
  }
127
- /** Text of the first shown error, for the `validate` event detail. */
312
+ /** Text of the first shown error, for validation detail and announcement. */
128
313
  #shownMessage() {
129
314
  return (this.#shownErrors()[0]?.textContent ?? "").trim();
130
315
  }
@@ -135,17 +320,50 @@ var FormFieldController = class _FormFieldController extends Controller {
135
320
  return typeof message === "string" ? message : null;
136
321
  }
137
322
  /**
138
- * Tokens already in the control's `aria-describedby` that are not ids of this
139
- * controller's own description/error targets.
323
+ * Tokens authored on a newly adopted control, excluding ids this controller
324
+ * owns through its current description/error targets.
140
325
  */
141
- #externalDescribedByTokens() {
142
- if (!this.hasControlTarget) return [];
326
+ #externalDescribedByTokens(control) {
143
327
  const owned = /* @__PURE__ */ new Set([
144
328
  ...this.descriptionTargets.map((description) => description.id),
145
329
  ...this.errorTargets.map((error) => error.id)
146
330
  ]);
147
- const existing = this.controlTarget.getAttribute("aria-describedby") ?? "";
148
- return existing.split(/\s+/).filter((token) => token.length > 0 && !owned.has(token));
331
+ const existing = control.getAttribute("aria-describedby") ?? "";
332
+ return this.#uniqueTokens(
333
+ existing.split(/\s+/).filter((token) => token.length > 0 && !owned.has(token))
334
+ );
335
+ }
336
+ /** Deduplicates ARIA tokens without disturbing their first occurrence. */
337
+ #uniqueTokens(tokens) {
338
+ const seen = /* @__PURE__ */ new Set();
339
+ return tokens.filter((token) => {
340
+ if (token.length === 0 || seen.has(token)) return false;
341
+ seen.add(token);
342
+ return true;
343
+ });
344
+ }
345
+ /** Returns unique current targets in DOM order before serializing IDREF lists. */
346
+ #orderedElements(elements) {
347
+ const candidates = new Set(elements);
348
+ return [this.element, ...this.element.querySelectorAll("*")].filter(
349
+ (element) => candidates.has(element)
350
+ );
351
+ }
352
+ /** Whether one retained-node mutation can alter this controller's derived state. */
353
+ #isRelevantMutation(record) {
354
+ const target = record.target;
355
+ if (record.type === "attributes") {
356
+ if (record.attributeName === "hidden")
357
+ return this.errorTargets.includes(target);
358
+ return record.attributeName === "id" && [...this.descriptionTargets, ...this.errorTargets].includes(target);
359
+ }
360
+ return this.errorTargets.some((error) => error === target || error.contains(target));
361
+ }
362
+ /** Returns borrowed control ARIA before Turbo snapshots the page. */
363
+ #rewindForCache() {
364
+ this.#ariaDescribedBy.returnAll();
365
+ this.#ariaErrorMessage.returnAll();
366
+ this.#ariaInvalid.returnAll();
149
367
  }
150
368
  };
151
369
 
@@ -1 +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 (only `control` is required), so the DOM invalid state\n // 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"]}
1
+ {"version":3,"sources":["../../src/utils/announce.ts","../../src/utils/aria_ids.ts","../../src/utils/attribute_lease.ts","../../src/utils/before_cache_reset.ts","../../src/utils/microtask_coalescer.ts","../../src/controllers/form_field_controller.ts"],"names":[],"mappings":";;;;;AAoBO,SAAS,QAAA,CAAS,OAAA,EAAiB,OAAA,GAAmC,EAAC,EAAS;AACrF,EAAA,MAAM,IAAA,GAAO,QAAQ,IAAA,EAAK;AAC1B,EAAA,IAAI,IAAA,CAAK,WAAW,CAAA,EAAG;AACvB,EAAA,MAAA,CAAO,aAAA;AAAA,IACL,IAAI,YAAY,4BAAA,EAA8B;AAAA,MAC5C,QAAQ,EAAE,OAAA,EAAS,MAAM,SAAA,EAAW,OAAA,CAAQ,cAAc,IAAA;AAAK,KAChE;AAAA,GACH;AACF;;;ACVA,IAAI,OAAA,GAAU,CAAA;AAeP,SAAS,QAAA,CAAS,SAAS,QAAA,EAAkB;AAClD,EAAA,IAAI,SAAA;AACJ,EAAA,GAAG;AACD,IAAA,OAAA,IAAW,CAAA;AACX,IAAA,SAAA,GAAY,CAAA,EAAG,MAAM,CAAA,CAAA,EAAI,OAAO,CAAA,CAAA;AAAA,EAClC,SAAS,OAAO,QAAA,KAAa,eAAe,QAAA,CAAS,cAAA,CAAe,SAAS,CAAA,KAAM,IAAA;AACnF,EAAA,OAAO,SAAA;AACT;AAYO,SAAS,QAAA,CAAS,OAAA,EAAkB,MAAA,GAAS,QAAA,EAAkB;AACpE,EAAA,IAAI,OAAA,CAAQ,EAAA,EAAI,OAAO,OAAA,CAAQ,EAAA;AAC/B,EAAA,MAAM,EAAA,GAAK,SAAS,MAAM,CAAA;AAC1B,EAAA,OAAA,CAAQ,EAAA,GAAK,EAAA;AACb,EAAA,OAAO,EAAA;AACT;;;ACjCO,IAAM,iBAAN,MAAkD;AAAA,EAC9C,UAAA;AAAA,EACA,QAAA,uBAAe,GAAA,EAA6B;AAAA;AAAA,EAGrD,YAAY,SAAA,EAAmB;AAC7B,IAAA,IAAA,CAAK,UAAA,GAAa,SAAA;AAAA,EACpB;AAAA;AAAA,EAGA,KAAA,CAAM,SAAY,KAAA,EAA4B;AAC5C,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,QAAA,CAAS,GAAA,CAAI,OAAO,CAAA;AAC1C,IAAA,IAAI,QAAA,EAAU;AACZ,MAAA,QAAA,CAAS,OAAA,GAAU,KAAA;AAAA,IACrB,CAAA,MAAO;AACL,MAAA,IAAA,CAAK,QAAA,CAAS,IAAI,OAAA,EAAS;AAAA,QACzB,QAAA,EAAU,OAAA,CAAQ,YAAA,CAAa,IAAA,CAAK,UAAU,CAAA;AAAA,QAC9C,OAAA,EAAS;AAAA,OACV,CAAA;AAAA,IACH;AAEA,IAAA,IAAA,CAAK,QAAA,CAAS,SAAS,KAAK,CAAA;AAAA,EAC9B;AAAA;AAAA,EAGA,OAAO,OAAA,EAAkB;AACvB,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,QAAA,CAAS,GAAA,CAAI,OAAO,CAAA;AACxC,IAAA,IAAI,CAAC,MAAA,EAAQ;AACb,IAAA,IAAA,CAAK,QAAA,CAAS,OAAO,OAAO,CAAA;AAC5B,IAAA,MAAM,aAAa,OAAA,CAAQ,YAAA,CAAa,IAAA,CAAK,UAAU,MAAM,MAAA,CAAO,OAAA;AACpE,IAAA,IAAI,UAAA,EAAY,IAAA,CAAK,QAAA,CAAS,OAAA,EAAS,OAAO,QAAQ,CAAA;AAAA,EACxD;AAAA;AAAA,EAGA,QAAA,CAAS,SAAY,KAAA,EAA4B;AAC/C,IAAA,IAAI,OAAA,CAAQ,YAAA,CAAa,IAAA,CAAK,UAAU,MAAM,KAAA,EAAO;AACrD,IAAA,IAAI,KAAA,KAAU,IAAA,EAAM,OAAA,CAAQ,eAAA,CAAgB,KAAK,UAAU,CAAA;AAAA,SACtD,OAAA,CAAQ,YAAA,CAAa,IAAA,CAAK,UAAA,EAAY,KAAK,CAAA;AAAA,EAClD;AAAA;AAAA,EAGA,SAAA,GAAkB;AAChB,IAAA,KAAA,MAAW,OAAA,IAAW,KAAA,CAAM,IAAA,CAAK,IAAA,CAAK,QAAA,CAAS,MAAM,CAAA,EAAG,IAAA,CAAK,MAAA,CAAO,OAAO,CAAA;AAAA,EAC7E;AACF,CAAA;;;ACzBO,IAAM,gBAAA,GAAN,MAAM,iBAAA,CAAiB;AAAA;AAAA,EAE5B,OAAgB,YAAA,mBAAe,IAAI,GAAA,EAAsB;AAAA;AAAA,EAGzD,OAAgB,iBAAiB,MAAY;AAC3C,IAAA,KAAA,MAAW,UAAA,IAAc,iBAAA,CAAiB,YAAA,EAAc,UAAA,CAAW,OAAA,EAAQ;AAAA,EAC7E,CAAA;AAAA,EAES,OAAA;AAAA;AAAA,EAGT,YAAY,MAAA,EAAoB;AAC9B,IAAA,IAAA,CAAK,OAAA,GAAU,MAAA;AAAA,EACjB;AAAA;AAAA,EAGA,QAAA,GAAiB;AACf,IAAA,MAAM,KAAA,GAAQ,iBAAA,CAAiB,YAAA,CAAa,IAAA,KAAS,CAAA;AACrD,IAAA,iBAAA,CAAiB,YAAA,CAAa,IAAI,IAAI,CAAA;AACtC,IAAA,IAAI,KAAA,EAAO;AACT,MAAA,QAAA,CAAS,gBAAA,CAAiB,oBAAA,EAAsB,iBAAA,CAAiB,cAAc,CAAA;AAAA,IACjF;AAAA,EACF;AAAA;AAAA,EAGA,UAAA,GAAmB;AACjB,IAAA,iBAAA,CAAiB,YAAA,CAAa,OAAO,IAAI,CAAA;AACzC,IAAA,IAAI,iBAAA,CAAiB,YAAA,CAAa,IAAA,GAAO,CAAA,EAAG;AAC5C,IAAA,QAAA,CAAS,mBAAA,CAAoB,oBAAA,EAAsB,iBAAA,CAAiB,cAAc,CAAA;AAAA,EACpF;AACF,CAAA;;;ACrBO,IAAM,qBAAN,MAAyB;AAAA,EACrB,IAAA;AAAA,EACT,OAAA,GAAU,KAAA;AAAA,EACV,OAAA,GAAU,KAAA;AAAA,EACV,WAAA,GAAc,CAAA;AAAA;AAAA,EAGd,YAAY,GAAA,EAAiB;AAC3B,IAAA,IAAA,CAAK,IAAA,GAAO,GAAA;AAAA,EACd;AAAA;AAAA,EAGA,QAAA,GAAiB;AACf,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AAAA,EACjB;AAAA;AAAA,EAGA,MAAA,GAAe;AACb,IAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,IAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,IAAA,IAAA,CAAK,WAAA,IAAe,CAAA;AAAA,EACtB;AAAA;AAAA,EAGA,QAAA,GAAiB;AACf,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,IAAW,IAAA,CAAK,OAAA,EAAS;AACnC,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AACf,IAAA,MAAM,aAAa,IAAA,CAAK,WAAA;AACxB,IAAA,cAAA,CAAe,MAAM;AAEnB,MAAA,IAAI,UAAA,KAAe,KAAK,WAAA,IAAe,CAAC,KAAK,OAAA,IAAW,CAAC,KAAK,OAAA,EAAS;AACvE,MAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,MAAA,IAAA,CAAK,IAAA,EAAK;AAAA,IACZ,CAAC,CAAA;AAAA,EACH;AACF,CAAA;;;ACzDA,IAAM,mBAAA,GAAsB,CAAC,QAAA,EAAU,IAAI,CAAA;AAwCpC,IAAM,mBAAA,GAAN,MAAM,oBAAA,SAA4B,UAAA,CAAwB;AAAA,EAC/D,OAAgB,OAAA,GAAU,CAAC,SAAA,EAAW,eAAe,OAAO,CAAA;AAAA,EAC5D,OAAgB,MAAA,GAAS;AAAA,IACvB,YAAA,EAAc,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,KAAA;AAAM,GAChD;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,YAAA,EAAc,UAAU,CAAA;AAAA,EAC1C,OAAO,MAAA,GAAS,CAAC,UAAU,CAAA;AAAA;AAAA,EAW3B,OAAgB,aAAA,GAAgB,iCAAA;AAAA;AAAA,EAGvB,aAAa,IAAI,kBAAA,CAAmB,MAAM,IAAA,CAAK,eAAe,CAAA;AAAA;AAAA,EAE9D,eAAe,IAAI,gBAAA,CAAiB,MAAM,IAAA,CAAK,iBAAiB,CAAA;AAAA,EAChE,gBAAA,GAAmB,IAAI,cAAA,CAA4B,kBAAkB,CAAA;AAAA,EACrE,iBAAA,GAAoB,IAAI,cAAA,CAA4B,mBAAmB,CAAA;AAAA,EACvE,YAAA,GAAe,IAAI,cAAA,CAA4B,cAAc,CAAA;AAAA;AAAA,EAE7D,SAAA,GAAY,IAAI,gBAAA,CAAiB,CAAC,OAAA,KAAY;AACrD,IAAA,IAAI,OAAA,CAAQ,KAAK,CAAC,MAAA,KAAW,KAAK,mBAAA,CAAoB,MAAM,CAAC,CAAA,EAAG;AAC9D,MAAA,IAAA,CAAK,WAAW,QAAA,EAAS;AAAA,IAC3B;AAAA,EACF,CAAC,CAAA;AAAA,EAED,cAAA,GAAqC,IAAA;AAAA,EACrC,mBAA6B,EAAC;AAAA,EAC9B,gBAAA,GAAmB,KAAA;AAAA,EACnB,YAAA,GAAe,KAAA;AAAA;AAAA,EAGN,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,WAAW,QAAA,EAAS;AACzB,IAAA,IAAA,CAAK,qBAAA,EAAsB;AAC3B,IAAA,IAAA,CAAK,aAAa,QAAA,EAAS;AAK3B,IAAA,IAAI,CAAC,KAAK,YAAA,EAAc;AACtB,MAAA,IAAA,CAAK,gBAAA,GACH,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,oBAAA,CAAoB,aAAa,CAAA,IAC3D,IAAA,CAAK,YAAA,EAAa,CAAE,MAAA,KAAW,CAAA;AACjC,MAAA,IAAA,CAAK,YAAA,GAAe,IAAA;AAAA,IACtB;AAEA,IAAA,IAAA,CAAK,aAAA,EAAc;AACnB,IAAA,IAAA,CAAK,SAAA,CAAU,OAAA,CAAQ,IAAA,CAAK,OAAA,EAAS;AAAA,MACnC,UAAA,EAAY,IAAA;AAAA,MACZ,eAAA,EAAiB,mBAAA;AAAA,MACjB,aAAA,EAAe,IAAA;AAAA,MACf,SAAA,EAAW,IAAA;AAAA,MACX,OAAA,EAAS;AAAA,KACV,CAAA;AAAA,EACH;AAAA;AAAA,EAGS,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,WAAW,MAAA,EAAO;AACvB,IAAA,IAAA,CAAK,UAAU,UAAA,EAAW;AAC1B,IAAA,IAAA,CAAK,aAAa,UAAA,EAAW;AAC7B,IAAA,IAAA,CAAK,cAAA,IAAkB,IAAA,CAAK,eAAA,CAAgB,IAAA,CAAK,cAAc,CAAA;AAAA,EACjE;AAAA;AAAA,EAGA,sBAAA,GAA+B;AAC7B,IAAA,IAAA,CAAK,WAAW,QAAA,EAAS;AAAA,EAC3B;AAAA;AAAA,EAGA,yBAAA,GAAkC;AAChC,IAAA,IAAA,CAAK,WAAW,QAAA,EAAS;AAAA,EAC3B;AAAA;AAAA,EAGA,0BAAA,GAAmC;AACjC,IAAA,IAAA,CAAK,WAAW,QAAA,EAAS;AAAA,EAC3B;AAAA;AAAA,EAGA,6BAAA,GAAsC;AACpC,IAAA,IAAA,CAAK,WAAW,QAAA,EAAS;AAAA,EAC3B;AAAA;AAAA,EAGA,oBAAA,GAA6B;AAC3B,IAAA,IAAA,CAAK,WAAW,QAAA,EAAS;AAAA,EAC3B;AAAA;AAAA,EAGA,uBAAA,GAAgC;AAC9B,IAAA,IAAA,CAAK,WAAW,QAAA,EAAS;AAAA,EAC3B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAcA,QAAA,CAAS,GAAA,EAA4B,OAAA,GAAoC,EAAC,EAAS;AACjF,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,eAAA,CAAgB,GAAG,CAAA;AACxC,IAAA,IAAI,OAAA,KAAY,IAAA,IAAQ,IAAA,CAAK,cAAA,EAAgB;AAC3C,MAAA,IAAA,CAAK,aAAa,CAAC,CAAA,EAAG,gBAAgB,QAAA,CAAS,cAAA,CAAe,OAAO,CAAC,CAAA;AAAA,IACxE;AACA,IAAA,KAAA,MAAW,KAAA,IAAS,KAAK,YAAA,EAAc;AACrC,MAAA,KAAA,CAAM,MAAA,GAAA,CAAU,KAAA,CAAM,WAAA,IAAe,EAAA,EAAI,MAAK,KAAM,EAAA;AAAA,IACtD;AAIA,IAAA,IAAA,CAAK,gBAAA,GAAmB,IAAA;AACxB,IAAA,IAAA,CAAK,aAAA,EAAc;AACnB,IAAA,MAAM,YAAA,GAAe,KAAK,aAAA,EAAc;AACxC,IAAA,MAAM,MAAA,GAAkC,EAAE,KAAA,EAAO,KAAA,EAAO,SAAS,YAAA,EAAa;AAC9E,IAAA,IAAA,CAAK,QAAA,CAAS,UAAA,EAAY,EAAE,MAAA,EAAQ,CAAA;AACpC,IAAA,QAAA,CAAS,YAAA,EAAc,EAAE,SAAA,EAAW,IAAA,EAAM,CAAA;AAE1C,IAAA,IAAI,QAAQ,KAAA,IAAS,IAAA,CAAK,iBAAA,EAAmB,IAAA,CAAK,gBAAgB,KAAA,EAAM;AAAA,EAC1E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,UAAA,GAAmB;AACjB,IAAA,KAAA,MAAW,KAAA,IAAS,KAAK,YAAA,EAAc;AACrC,MAAA,KAAA,CAAM,eAAA,EAAgB;AACtB,MAAA,KAAA,CAAM,MAAA,GAAS,IAAA;AAAA,IACjB;AACA,IAAA,IAAA,CAAK,gBAAA,GAAmB,KAAA;AACxB,IAAA,IAAA,CAAK,aAAA,EAAc;AACnB,IAAA,MAAM,MAAA,GAAkC,EAAE,KAAA,EAAO,IAAA,EAAM,SAAS,EAAA,EAAG;AACnE,IAAA,IAAA,CAAK,QAAA,CAAS,UAAA,EAAY,EAAE,MAAA,EAAQ,CAAA;AAAA,EACtC;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,aAAA,GAAsB;AACpB,IAAA,IAAA,CAAK,qBAAA,EAAsB;AAC3B,IAAA,IAAA,CAAK,oBAAA,EAAqB;AAE1B,IAAA,MAAM,KAAA,GAAQ,KAAK,YAAA,EAAa;AAChC,IAAA,MAAM,OAAA,GAAU,IAAA,CAAK,gBAAA,IAAoB,KAAA,CAAM,MAAA,GAAS,CAAA;AACxD,IAAA,IAAA,CAAK,OAAA,CAAQ,eAAA,CAAgB,oBAAA,CAAoB,aAAA,EAAe,OAAO,CAAA;AAEvE,IAAA,MAAM,UAAU,IAAA,CAAK,cAAA;AACrB,IAAA,IAAI,CAAC,OAAA,EAAS;AAEd,IAAA,IAAA,CAAK,YAAA,CAAa,KAAA,CAAM,OAAA,EAAS,OAAA,GAAU,SAAS,OAAO,CAAA;AAE3D,IAAA,MAAM,cAAA,GAAiB,KAAA,CAAM,CAAC,CAAA,EAAG,EAAA,IAAM,IAAA;AACvC,IAAA,IAAA,CAAK,iBAAA,CAAkB,KAAA,CAAM,OAAA,EAAS,cAAc,CAAA;AAEpD,IAAA,MAAM,cAAA,GAAiB,IAAA,CAAK,gBAAA,CAAiB,CAAC,GAAG,KAAK,kBAAA,EAAoB,GAAG,KAAK,CAAC,CAAA,CAAE,GAAA;AAAA,MACnF,CAAC,YAAY,OAAA,CAAQ;AAAA,KACvB;AACA,IAAA,MAAM,WAAA,GAAc,KAAK,aAAA,CAAc,CAAC,GAAG,IAAA,CAAK,gBAAA,EAAkB,GAAG,cAAc,CAAC,CAAA;AACpF,IAAA,IAAA,CAAK,gBAAA,CAAiB,KAAA,CAAM,OAAA,EAAS,WAAA,CAAY,MAAA,GAAS,IAAI,WAAA,CAAY,IAAA,CAAK,GAAG,CAAA,GAAI,IAAI,CAAA;AAAA,EAC5F;AAAA;AAAA,EAGA,qBAAA,GAA8B;AAC5B,IAAA,KAAA,MAAW,WAAA,IAAe,KAAK,kBAAA,EAAoB;AACjD,MAAA,QAAA,CAAS,aAAa,yBAAyB,CAAA;AAAA,IACjD;AACA,IAAA,KAAA,MAAW,KAAA,IAAS,KAAK,YAAA,EAAc;AACrC,MAAA,QAAA,CAAS,OAAO,0BAA0B,CAAA;AAAA,IAC5C;AAAA,EACF;AAAA;AAAA,EAGA,oBAAA,GAA6B;AAC3B,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,gBAAA,GAAmB,IAAA,CAAK,aAAA,GAAgB,IAAA;AAC1D,IAAA,IAAI,IAAA,KAAS,KAAK,cAAA,EAAgB;AAClC,IAAA,IAAA,CAAK,cAAA,IAAkB,IAAA,CAAK,eAAA,CAAgB,IAAA,CAAK,cAAc,CAAA;AAC/D,IAAA,IAAA,CAAK,cAAA,GAAiB,IAAA;AACtB,IAAA,IAAA,CAAK,mBAAmB,IAAA,GAAO,IAAA,CAAK,0BAAA,CAA2B,IAAI,IAAI,EAAC;AAAA,EAC1E;AAAA;AAAA,EAGA,gBAAgB,OAAA,EAA4B;AAC1C,IAAA,IAAA,CAAK,gBAAA,CAAiB,OAAO,OAAO,CAAA;AACpC,IAAA,IAAA,CAAK,iBAAA,CAAkB,OAAO,OAAO,CAAA;AACrC,IAAA,IAAA,CAAK,YAAA,CAAa,OAAO,OAAO,CAAA;AAChC,IAAA,IAAI,OAAA,KAAY,KAAK,cAAA,EAAgB;AACnC,MAAA,IAAA,CAAK,cAAA,GAAiB,IAAA;AACtB,MAAA,IAAA,CAAK,mBAAmB,EAAC;AAAA,IAC3B;AAAA,EACF;AAAA;AAAA,EAGA,YAAA,GAA8B;AAC5B,IAAA,OAAO,IAAA,CAAK,gBAAA;AAAA,MACV,IAAA,CAAK,YAAA,CAAa,MAAA,CAAO,CAAC,KAAA,KAAU,CAAC,KAAA,CAAM,MAAA,IAAA,CAAW,KAAA,CAAM,WAAA,IAAe,EAAA,EAAI,IAAA,OAAW,EAAE;AAAA,KAC9F;AAAA,EACF;AAAA;AAAA,EAGA,aAAA,GAAwB;AACtB,IAAA,OAAA,CAAQ,KAAK,YAAA,EAAa,CAAE,CAAC,CAAA,EAAG,WAAA,IAAe,IAAI,IAAA,EAAK;AAAA,EAC1D;AAAA;AAAA,EAGA,gBAAgB,GAAA,EAA2C;AACzD,IAAA,IAAI,OAAO,GAAA,KAAQ,QAAA,EAAU,OAAO,GAAA;AACpC,IAAA,MAAM,OAAA,GAAU,KAAK,MAAA,EAAQ,OAAA;AAC7B,IAAA,OAAO,OAAO,OAAA,KAAY,QAAA,GAAW,OAAA,GAAU,IAAA;AAAA,EACjD;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,2BAA2B,OAAA,EAAgC;AACzD,IAAA,MAAM,KAAA,uBAAY,GAAA,CAAI;AAAA,MACpB,GAAG,IAAA,CAAK,kBAAA,CAAmB,IAAI,CAAC,WAAA,KAAgB,YAAY,EAAE,CAAA;AAAA,MAC9D,GAAG,IAAA,CAAK,YAAA,CAAa,IAAI,CAAC,KAAA,KAAU,MAAM,EAAE;AAAA,KAC7C,CAAA;AACD,IAAA,MAAM,QAAA,GAAW,OAAA,CAAQ,YAAA,CAAa,kBAAkB,CAAA,IAAK,EAAA;AAC7D,IAAA,OAAO,IAAA,CAAK,aAAA;AAAA,MACV,QAAA,CAAS,KAAA,CAAM,KAAK,CAAA,CAAE,OAAO,CAAC,KAAA,KAAU,KAAA,CAAM,MAAA,GAAS,CAAA,IAAK,CAAC,KAAA,CAAM,GAAA,CAAI,KAAK,CAAC;AAAA,KAC/E;AAAA,EACF;AAAA;AAAA,EAGA,cAAc,MAAA,EAA4B;AACxC,IAAA,MAAM,IAAA,uBAAW,GAAA,EAAY;AAC7B,IAAA,OAAO,MAAA,CAAO,MAAA,CAAO,CAAC,KAAA,KAAU;AAC9B,MAAA,IAAI,MAAM,MAAA,KAAW,CAAA,IAAK,KAAK,GAAA,CAAI,KAAK,GAAG,OAAO,KAAA;AAClD,MAAA,IAAA,CAAK,IAAI,KAAK,CAAA;AACd,MAAA,OAAO,IAAA;AAAA,IACT,CAAC,CAAA;AAAA,EACH;AAAA;AAAA,EAGA,iBAAiB,QAAA,EAAwC;AACvD,IAAA,MAAM,UAAA,GAAa,IAAI,GAAA,CAAI,QAAQ,CAAA;AACnC,IAAA,OAAO,CAAC,KAAK,OAAA,EAAS,GAAG,KAAK,OAAA,CAAQ,gBAAA,CAA8B,GAAG,CAAC,CAAA,CAAE,MAAA;AAAA,MAAO,CAAC,OAAA,KAChF,UAAA,CAAW,GAAA,CAAI,OAAO;AAAA,KACxB;AAAA,EACF;AAAA;AAAA,EAGA,oBAAoB,MAAA,EAAiC;AACnD,IAAA,MAAM,SAAS,MAAA,CAAO,MAAA;AACtB,IAAA,IAAI,MAAA,CAAO,SAAS,YAAA,EAAc;AAChC,MAAA,IAAI,OAAO,aAAA,KAAkB,QAAA;AAC3B,QAAA,OAAO,IAAA,CAAK,YAAA,CAAa,QAAA,CAAS,MAAqB,CAAA;AACzD,MAAA,OACE,MAAA,CAAO,aAAA,KAAkB,IAAA,IACzB,CAAC,GAAG,IAAA,CAAK,kBAAA,EAAoB,GAAG,IAAA,CAAK,YAAY,CAAA,CAAE,QAAA,CAAS,MAAqB,CAAA;AAAA,IAErF;AAEA,IAAA,OAAO,IAAA,CAAK,YAAA,CAAa,IAAA,CAAK,CAAC,KAAA,KAAU,UAAU,MAAA,IAAU,KAAA,CAAM,QAAA,CAAS,MAAM,CAAC,CAAA;AAAA,EACrF;AAAA;AAAA,EAEA,eAAA,GAAwB;AACtB,IAAA,IAAA,CAAK,iBAAiB,SAAA,EAAU;AAChC,IAAA,IAAA,CAAK,kBAAkB,SAAA,EAAU;AACjC,IAAA,IAAA,CAAK,aAAa,SAAA,EAAU;AAAA,EAC9B;AACF","file":"form_field_controller.js","sourcesContent":["/**\n * Sends one message to the page's shared `stimeo--announcer`.\n *\n * A component that has to reach assistive tech does not carry a live region of its\n * own: a region only announces what changes *after* assistive tech already knows\n * about it, which a region that appears (or is un-hidden) with its message cannot\n * satisfy. The one region that can is the announcer sitting in the page from the\n * start, so state changes are handed to it as an event and it does the reading.\n *\n * The event goes to `window` because the announcer is usually a sibling high in the\n * document rather than an ancestor of the component dispatching it.\n *\n * Wording comes from the consumer — the library ships no English strings — so an\n * empty message is silently dropped and nothing is announced.\n *\n * @example\n * ```ts\n * announce(this.announceTextValue, { assertive: false });\n * ```\n */\nexport function announce(message: string, options: { assertive?: boolean } = {}): void {\n const text = message.trim();\n if (text.length === 0) return;\n window.dispatchEvent(\n new CustomEvent(\"stimeo--announcer:announce\", {\n detail: { message: text, assertive: options.assertive === true },\n }),\n );\n}\n\n/**\n * Fills `{name}` placeholders in an announcement template from `values`.\n *\n * The same substitution the value-text templates use, so a consumer writes\n * `\"{percent}% complete\"` in one attribute and gets the same rules everywhere. A\n * placeholder with no matching entry is left as authored rather than blanked, which\n * keeps a typo visible instead of silently swallowing the word.\n */\nexport function fillTemplate(template: string, values: Record<string, string | number>): string {\n return template.replace(/\\{([a-zA-Z][a-zA-Z0-9]*)\\}/g, (match, name: string) => {\n const replacement = values[name];\n return replacement === undefined ? match : String(replacement);\n });\n}\n","/**\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","/** One temporarily controlled attribute value and the authored value it displaced. */\ninterface AttributeLeaseRecord {\n readonly original: string | null;\n written: string | null;\n}\n\n/**\n * Temporarily controls one attribute across a changing set of elements.\n *\n * The first write remembers the authored value, including the distinction between\n * an absent attribute and an authored empty string. Returning a lease restores that\n * value only while the attribute still matches the controller's last write. If a\n * consumer changed it in the meantime, the consumer owns the new value and teardown\n * leaves it alone.\n *\n * A `null` write deliberately removes the attribute while retaining the lease. This\n * is useful for derived ARIA whose valid absence is itself controller state, such as\n * an unbounded `aria-valuemin` or a blank spinbutton's `aria-valuenow`.\n *\n * The lease has no lifecycle of its own, so it never subscribes to document events.\n * A consumer that must not let a derived value become the authored baseline of a\n * cached page returns its leases from its own `turbo:before-cache` rewind, where the\n * matching `disconnect()` also releases the subscription.\n */\nexport class AttributeLease<T extends Element = Element> {\n readonly #attribute: string;\n readonly #records = new Map<T, AttributeLeaseRecord>();\n\n /** @param attribute - The attribute whose temporary values this lease owns. */\n constructor(attribute: string) {\n this.#attribute = attribute;\n }\n\n /** Writes or removes the leased attribute while preserving its authored value. */\n write(element: T, value: string | null): void {\n const existing = this.#records.get(element);\n if (existing) {\n existing.written = value;\n } else {\n this.#records.set(element, {\n original: element.getAttribute(this.#attribute),\n written: value,\n });\n }\n\n this.#reflect(element, value);\n }\n\n /** Returns one lease without overwriting a value subsequently authored by a consumer. */\n return(element: T): void {\n const record = this.#records.get(element);\n if (!record) return;\n this.#records.delete(element);\n const stillOwned = element.getAttribute(this.#attribute) === record.written;\n if (stillOwned) this.#reflect(element, record.original);\n }\n\n /** Reflects only a real value transition, avoiding self-triggered mutation work. */\n #reflect(element: T, value: string | null): void {\n if (element.getAttribute(this.#attribute) === value) return;\n if (value === null) element.removeAttribute(this.#attribute);\n else element.setAttribute(this.#attribute, value);\n }\n\n /** Returns every outstanding lease using the same ownership check as {@link return}. */\n returnAll(): void {\n for (const element of Array.from(this.#records.keys())) this.return(element);\n }\n}\n","/**\n * Runs a controller's \"return to the initial state\" pass just before Turbo\n * caches the page.\n *\n * **`disconnect()` cannot do this job, for two independent reasons.** Turbo\n * queues the clone from this event rather than taking it here, and the body swap\n * that runs the controller's `disconnect()` is queued separately — so which of\n * the two lands first is not something a controller can rely on, and a rewind\n * written in `disconnect()` may reach only the DOM being thrown away. In the\n * other direction, `disconnect()` also fires on an in-page move (Stimulus tears\n * down and reconnects the same element), where rewinding would wipe a\n * legitimately in-progress interaction — a spinner mid-load would vanish. One\n * timing is unreliable, the other is too eager; `turbo:before-cache` is the only\n * point that is exactly \"the page is about to be frozen\".\n *\n * Scope is the subscription only: registering on `activate()`, unregistering on\n * `deactivate()`, and one shared document listener no matter how many instances\n * are live. *What* to return to its initial state — which `data-state`, which\n * `hidden`, which `aria-busy` — stays in the controller, because no two\n * consumers answer it the same way (the `MicrotaskCoalescer` split).\n *\n * **Rewind state, not appearance.** The pass writes attributes the controller\n * itself owns; the visual result of those attributes is the consumer's CSS, and\n * a library that reached for style or class names would be guessing at markup\n * it does not own.\n *\n * Both entry points are idempotent, so the lifecycle hooks can call them\n * unconditionally: a second `activate()` does not double-subscribe and does not\n * make the callback run twice, and `deactivate()` on an instance that never\n * subscribed is a no-op.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #beforeCache = new BeforeCacheReset(() => this.#rewind());\n *\n * connect() { this.#beforeCache.activate(); }\n * disconnect() { this.#beforeCache.deactivate(); }\n * ```\n */\nexport class BeforeCacheReset {\n /** Every subscribed instance, iterated by the one shared document listener. */\n static readonly #subscribers = new Set<BeforeCacheReset>();\n\n /** The shared listener; installed while at least one instance is subscribed. */\n static readonly #onBeforeCache = (): void => {\n for (const subscriber of BeforeCacheReset.#subscribers) subscriber.#rewind();\n };\n\n readonly #rewind: () => void;\n\n /** @param rewind - the pass that returns this controller's state to its initial form. */\n constructor(rewind: () => void) {\n this.#rewind = rewind;\n }\n\n /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */\n activate(): void {\n const first = BeforeCacheReset.#subscribers.size === 0;\n BeforeCacheReset.#subscribers.add(this);\n if (first) {\n document.addEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n }\n\n /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */\n deactivate(): void {\n BeforeCacheReset.#subscribers.delete(this);\n if (BeforeCacheReset.#subscribers.size > 0) return;\n document.removeEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n}\n","/**\n * Collapses many Stimulus lifecycle callbacks from one DOM mutation into a\n * single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element and\n * `<name>ValueChanged` once per changed attribute. Replacing a list of N options\n * or morphing several render Values therefore delivers N callbacks — but the\n * useful unit of work is \"reconcile against the resulting declarative input\",\n * once, after the batch has settled. Every controller with reconcilable targets\n * or render Values needs the same shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers initial target and Value callbacks\n * ahead of `connect()`. Reconciling there would compute output against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { announce } from \"../utils/announce\";\nimport { ensureId } from \"../utils/aria_ids\";\nimport { AttributeLease } from \"../utils/attribute_lease\";\nimport { BeforeCacheReset } from \"../utils/before_cache_reset\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\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/** Programmatic behavior switches for {@link FormFieldController.setError}. */\nexport interface FormFieldSetErrorOptions {\n /** Override the `focusOnError` Value for this call. */\n focus?: boolean;\n}\n\n/** Detail dispatched with `stimeo--form-field:validate`. */\nexport interface FormFieldValidateDetail {\n /** Whether the explicit validation action left the field valid. */\n valid: boolean;\n /** The first visible error message, or an empty string when valid/unavailable. */\n message: string;\n}\n\n/** Attributes on retained association targets that can change the ARIA graph. */\nconst OBSERVED_ATTRIBUTES = [\"hidden\", \"id\"];\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 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 visual error.\n *\n * `validate` dispatches `{ valid: boolean, message: string }`.\n *\n * @remarks\n * Behavior only — it sets semantic attributes and the `hidden` state of the\n * visual error; it never validates input (the consumer / server decides) and\n * never styles. Runtime errors are handed to a separately seated\n * `stimeo--announcer`, because showing a live region together with its first\n * message is not a reliable announcement primitive.\n *\n * Behavior provided:\n * - Assigns ids to description/error targets and composes the control's\n * `aria-describedby` from them in DOM order (preserving the control's authored\n * tokens and removing duplicates).\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 * - Reconciles added, removed, replaced, and retained/morphed targets without\n * emitting validation events or announcements.\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 /** Whether an explicit {@link setError} call moves focus to the current control. */\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 /** Collapses one target/morph batch into one silent ARIA reconciliation. */\n readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());\n /** ARIA ownership is scoped to the current singular control target. */\n readonly #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());\n readonly #ariaDescribedBy = new AttributeLease<HTMLElement>(\"aria-describedby\");\n readonly #ariaErrorMessage = new AttributeLease<HTMLElement>(\"aria-errormessage\");\n readonly #ariaInvalid = new AttributeLease<HTMLElement>(\"aria-invalid\");\n /** Watches retained target ids, error visibility, and error content. */\n readonly #observer = new MutationObserver((records) => {\n if (records.some((record) => this.#isRelevantMutation(record))) {\n this.#reconcile.schedule();\n }\n });\n\n #activeControl: HTMLElement | null = null;\n #baseDescribedBy: string[] = [];\n #explicitInvalid = false;\n #initialized = false;\n\n /** Wires the initial graph and starts retained-target reconciliation. */\n override connect(): void {\n this.#reconcile.activate();\n this.#ensureAssociationIds();\n this.#beforeCache.activate();\n\n // A restored DOM can contain the invalid hook with no surviving visual\n // message. Treat that one shape as the persisted explicit setError state;\n // visible server errors remain derived and can clear when their DOM clears.\n if (!this.#initialized) {\n this.#explicitInvalid =\n this.element.hasAttribute(FormFieldController.#INVALID_ATTR) &&\n this.#shownErrors().length === 0;\n this.#initialized = true;\n }\n\n this.#reconcileDom();\n this.#observer.observe(this.element, {\n attributes: true,\n attributeFilter: OBSERVED_ATTRIBUTES,\n characterData: true,\n childList: true,\n subtree: true,\n });\n }\n\n /** Releases observers, queued work, and ARIA borrowed on the current control. */\n override disconnect(): void {\n this.#reconcile.cancel();\n this.#observer.disconnect();\n this.#beforeCache.deactivate();\n this.#activeControl && this.#releaseControl(this.#activeControl);\n }\n\n /** Reconciles a control inserted or replaced at runtime. */\n controlTargetConnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Schedules restoration of authored ARIA when a control leaves this field. */\n controlTargetDisconnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Reconciles a description inserted or replaced at runtime. */\n descriptionTargetConnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Removes a departed description from the control's association graph. */\n descriptionTargetDisconnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Reconciles an error region inserted or replaced at runtime. */\n errorTargetConnected(): void {\n this.#reconcile.schedule();\n }\n\n /** Removes a departed error from invalid state and the association graph. */\n errorTargetDisconnected(): void {\n this.#reconcile.schedule();\n }\n\n /**\n * Marks the field invalid and shows the error message. Bound via `data-action`\n * (`#setError`) or callable directly.\n *\n * A non-empty shown message is announced exactly once through the shared\n * assertive announcer. Initial/server reconciliation is deliberately silent.\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 * @param options - Programmatic overrides. Stimulus actions use the Values.\n */\n setError(arg?: string | ActionEvent, options: FormFieldSetErrorOptions = {}): 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\n // setError is an explicit invalid request: it remains invalid even if an\n // error target is absent or replaced before clearError is called.\n this.#explicitInvalid = true;\n this.#reconcileDom();\n const shownMessage = this.#shownMessage();\n const detail: FormFieldValidateDetail = { valid: false, message: shownMessage };\n this.dispatch(\"validate\", { detail });\n announce(shownMessage, { assertive: true });\n\n if (options.focus ?? this.focusOnErrorValue) this.#activeControl?.focus();\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 * Clearing is silent: the visual/ARIA state and validation event are sufficient,\n * while success wording remains an explicit consumer announcement.\n */\n clearError(): void {\n for (const error of this.errorTargets) {\n error.replaceChildren();\n error.hidden = true;\n }\n this.#explicitInvalid = false;\n this.#reconcileDom();\n const detail: FormFieldValidateDetail = { valid: true, message: \"\" };\n this.dispatch(\"validate\", { detail });\n }\n\n /**\n * Rebuilds ids and derived ARIA from the settled target graph without reporting\n * a user validation action.\n */\n #reconcileDom(): void {\n this.#ensureAssociationIds();\n this.#adoptCurrentControl();\n\n const shown = this.#shownErrors();\n const invalid = this.#explicitInvalid || shown.length > 0;\n this.element.toggleAttribute(FormFieldController.#INVALID_ATTR, invalid);\n\n const control = this.#activeControl;\n if (!control) return;\n\n this.#ariaInvalid.write(control, invalid ? \"true\" : \"false\");\n\n const primaryErrorId = shown[0]?.id ?? null;\n this.#ariaErrorMessage.write(control, primaryErrorId);\n\n const associationIds = this.#orderedElements([...this.descriptionTargets, ...shown]).map(\n (element) => element.id,\n );\n const describedBy = this.#uniqueTokens([...this.#baseDescribedBy, ...associationIds]);\n this.#ariaDescribedBy.write(control, describedBy.length > 0 ? describedBy.join(\" \") : null);\n }\n\n /** Assigns stable ids before either capture or reflection uses them. */\n #ensureAssociationIds(): 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 }\n\n /** Switches ARIA ownership when the singular current control target changes. */\n #adoptCurrentControl(): void {\n const next = this.hasControlTarget ? this.controlTarget : null;\n if (next === this.#activeControl) return;\n this.#activeControl && this.#releaseControl(this.#activeControl);\n this.#activeControl = next;\n this.#baseDescribedBy = next ? this.#externalDescribedByTokens(next) : [];\n }\n\n /** Restores one departed control without overwriting later consumer edits. */\n #releaseControl(control: HTMLElement): void {\n this.#ariaDescribedBy.return(control);\n this.#ariaErrorMessage.return(control);\n this.#ariaInvalid.return(control);\n if (control === this.#activeControl) {\n this.#activeControl = null;\n this.#baseDescribedBy = [];\n }\n }\n\n /** Error targets currently visible and non-empty, in document order. */\n #shownErrors(): HTMLElement[] {\n return this.#orderedElements(\n this.errorTargets.filter((error) => !error.hidden && (error.textContent ?? \"\").trim() !== \"\"),\n );\n }\n\n /** Text of the first shown error, for validation detail and announcement. */\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 authored on a newly adopted control, excluding ids this controller\n * owns through its current description/error targets.\n */\n #externalDescribedByTokens(control: HTMLElement): string[] {\n const owned = new Set([\n ...this.descriptionTargets.map((description) => description.id),\n ...this.errorTargets.map((error) => error.id),\n ]);\n const existing = control.getAttribute(\"aria-describedby\") ?? \"\";\n return this.#uniqueTokens(\n existing.split(/\\s+/).filter((token) => token.length > 0 && !owned.has(token)),\n );\n }\n\n /** Deduplicates ARIA tokens without disturbing their first occurrence. */\n #uniqueTokens(tokens: string[]): string[] {\n const seen = new Set<string>();\n return tokens.filter((token) => {\n if (token.length === 0 || seen.has(token)) return false;\n seen.add(token);\n return true;\n });\n }\n\n /** Returns unique current targets in DOM order before serializing IDREF lists. */\n #orderedElements(elements: HTMLElement[]): HTMLElement[] {\n const candidates = new Set(elements);\n return [this.element, ...this.element.querySelectorAll<HTMLElement>(\"*\")].filter((element) =>\n candidates.has(element),\n );\n }\n\n /** Whether one retained-node mutation can alter this controller's derived state. */\n #isRelevantMutation(record: MutationRecord): boolean {\n const target = record.target;\n if (record.type === \"attributes\") {\n if (record.attributeName === \"hidden\")\n return this.errorTargets.includes(target as HTMLElement);\n return (\n record.attributeName === \"id\" &&\n [...this.descriptionTargets, ...this.errorTargets].includes(target as HTMLElement)\n );\n }\n\n return this.errorTargets.some((error) => error === target || error.contains(target));\n }\n /** Returns borrowed control ARIA before Turbo snapshots the page. */\n #rewindForCache(): void {\n this.#ariaDescribedBy.returnAll();\n this.#ariaErrorMessage.returnAll();\n this.#ariaInvalid.returnAll();\n }\n}\n"]}
@@ -11,7 +11,7 @@ import { FormFieldController } from './form_field_controller.js';
11
11
  * <label for="email">Email</label>
12
12
  * <input id="email" type="email" required
13
13
  * data-stimeo--form-field-target="control" />
14
- * <p role="alert" hidden data-stimeo--form-field-target="error"></p>
14
+ * <p hidden data-stimeo--form-field-target="error"></p>
15
15
  * </div>
16
16
  * <button type="submit">Save</button>
17
17
  * </form>
@@ -24,23 +24,25 @@ import { FormFieldController } from './form_field_controller.js';
24
24
  * `aria-describedby`) therefore lives in exactly one place — `stimeo--form-field`,
25
25
  * reached through a Stimulus **outlet** — and is never re-implemented here.
26
26
  *
27
+ * `valid` dispatches `{}`; `invalid` dispatches `{ invalid: HTMLElement[] }`.
28
+ *
27
29
  * @remarks
28
30
  * Behavior only — validation **rules** stay in the markup (native HTML
29
31
  * constraints: `required`, `type`, `pattern`, `min`/`max`, …) or in the consumer's
30
32
  * own `setCustomValidity()` calls, which `checkValidity()` surfaces transparently.
31
33
  * It sets the form's `novalidate` so it can replace the browser's default error
32
- * bubbles with the accessible, in-page `role="alert"` regions, and restores the
33
- * attribute on disconnect.
34
+ * bubbles with accessible in-page visual errors plus the shared Announcer, and
35
+ * restores the attribute on disconnect.
34
36
  *
35
37
  * Two declarative escape hatches let a field **exceed** native validation with no
36
38
  * consumer JS (author them on the control):
37
- * - **Per-constraint messages** — `data-stimeo--form-field-message-<constraint>`
39
+ * - **Per-constraint messages** — `data-stimeo--form-validation-message-<constraint>`
38
40
  * (`value-missing`, `too-short`, `too-long`, `pattern-mismatch`, `type-mismatch`,
39
41
  * `range-overflow`, `range-underflow`, `step-mismatch`, `bad-input`), or a generic
40
- * `data-stimeo--form-field-message` fallback, override the shown text per failing
42
+ * `data-stimeo--form-validation-message` fallback, override the shown text per failing
41
43
  * `ValidityState` flag — controlled, localizable wording that also fixes headless
42
44
  * browsers returning an empty native `validationMessage`. Falls back to native.
43
- * - **`data-stimeo--form-field-disallow="whitespace"`** — a built-in custom rule
45
+ * - **`data-stimeo--form-validation-disallow="whitespace"`** — a built-in custom rule
44
46
  * rejecting a value that is blank after trimming (which slips past `required` /
45
47
  * `minlength`), wired through `setCustomValidity` so it blocks submit like any
46
48
  * native constraint.
@@ -116,8 +118,8 @@ declare class FormValidationController extends Controller<HTMLFormElement> {
116
118
  disconnect(): void;
117
119
  /**
118
120
  * Validates every control now, rendering or clearing each field's message, and
119
- * returns whether the whole form is valid. Marks every control touched so a
120
- * later input re-validates it. Bound via `data-action`
121
+ * returns whether the whole form is valid. Marks every field/group touched so
122
+ * a later input from any sibling re-validates it. Bound via `data-action`
121
123
  * (`#validate`) or callable directly (e.g. before a programmatic submit).
122
124
  */
123
125
  validate(): boolean;