stimeo-ui 0.6.0 → 0.8.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 (212) hide show
  1. package/CHANGELOG.md +124 -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/auto_submit_controller.d.ts +16 -3
  6. package/dist/controllers/auto_submit_controller.js +94 -16
  7. package/dist/controllers/auto_submit_controller.js.map +1 -1
  8. package/dist/controllers/avatar_controller.js +47 -5
  9. package/dist/controllers/avatar_controller.js.map +1 -1
  10. package/dist/controllers/breadcrumb_controller.d.ts +2 -0
  11. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  12. package/dist/controllers/bulk_select_controller.d.ts +2 -0
  13. package/dist/controllers/bulk_select_controller.js.map +1 -1
  14. package/dist/controllers/calendar_controller.d.ts +2 -0
  15. package/dist/controllers/calendar_controller.js.map +1 -1
  16. package/dist/controllers/carousel_controller.d.ts +81 -47
  17. package/dist/controllers/carousel_controller.js +451 -100
  18. package/dist/controllers/carousel_controller.js.map +1 -1
  19. package/dist/controllers/character_counter_controller.d.ts +52 -20
  20. package/dist/controllers/character_counter_controller.js +338 -63
  21. package/dist/controllers/character_counter_controller.js.map +1 -1
  22. package/dist/controllers/checkbox_controller.d.ts +3 -0
  23. package/dist/controllers/checkbox_controller.js.map +1 -1
  24. package/dist/controllers/color_picker_controller.d.ts +3 -0
  25. package/dist/controllers/color_picker_controller.js.map +1 -1
  26. package/dist/controllers/combobox_controller.d.ts +2 -0
  27. package/dist/controllers/combobox_controller.js.map +1 -1
  28. package/dist/controllers/command_palette_controller.d.ts +2 -0
  29. package/dist/controllers/command_palette_controller.js +75 -4
  30. package/dist/controllers/command_palette_controller.js.map +1 -1
  31. package/dist/controllers/conditional_fields_controller.d.ts +42 -14
  32. package/dist/controllers/conditional_fields_controller.js +345 -51
  33. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  34. package/dist/controllers/confirm_controller.d.ts +2 -0
  35. package/dist/controllers/confirm_controller.js +75 -4
  36. package/dist/controllers/confirm_controller.js.map +1 -1
  37. package/dist/controllers/count_up_controller.d.ts +2 -0
  38. package/dist/controllers/count_up_controller.js.map +1 -1
  39. package/dist/controllers/countdown_controller.d.ts +4 -0
  40. package/dist/controllers/countdown_controller.js.map +1 -1
  41. package/dist/controllers/currency_input_controller.d.ts +40 -4
  42. package/dist/controllers/currency_input_controller.js +305 -74
  43. package/dist/controllers/currency_input_controller.js.map +1 -1
  44. package/dist/controllers/data_grid_controller.d.ts +3 -0
  45. package/dist/controllers/data_grid_controller.js.map +1 -1
  46. package/dist/controllers/dialog_controller.js +75 -4
  47. package/dist/controllers/dialog_controller.js.map +1 -1
  48. package/dist/controllers/direct_upload_controller.d.ts +59 -25
  49. package/dist/controllers/direct_upload_controller.js +212 -46
  50. package/dist/controllers/direct_upload_controller.js.map +1 -1
  51. package/dist/controllers/dirty_form_controller.d.ts +14 -6
  52. package/dist/controllers/dirty_form_controller.js +192 -29
  53. package/dist/controllers/dirty_form_controller.js.map +1 -1
  54. package/dist/controllers/dismissible_controller.d.ts +2 -0
  55. package/dist/controllers/dismissible_controller.js +83 -18
  56. package/dist/controllers/dismissible_controller.js.map +1 -1
  57. package/dist/controllers/drawer_controller.js +75 -4
  58. package/dist/controllers/drawer_controller.js.map +1 -1
  59. package/dist/controllers/empty_state_controller.d.ts +2 -0
  60. package/dist/controllers/empty_state_controller.js.map +1 -1
  61. package/dist/controllers/file_dropzone_controller.d.ts +123 -27
  62. package/dist/controllers/file_dropzone_controller.js +386 -63
  63. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  64. package/dist/controllers/filter_controller.d.ts +2 -0
  65. package/dist/controllers/filter_controller.js.map +1 -1
  66. package/dist/controllers/flash_controller.d.ts +7 -1
  67. package/dist/controllers/flash_controller.js +3 -1
  68. package/dist/controllers/flash_controller.js.map +1 -1
  69. package/dist/controllers/focus_controller.d.ts +2 -0
  70. package/dist/controllers/focus_controller.js +75 -4
  71. package/dist/controllers/focus_controller.js.map +1 -1
  72. package/dist/controllers/form_field_controller.d.ts +50 -10
  73. package/dist/controllers/form_field_controller.js +280 -62
  74. package/dist/controllers/form_field_controller.js.map +1 -1
  75. package/dist/controllers/form_validation_controller.d.ts +10 -8
  76. package/dist/controllers/form_validation_controller.js +208 -83
  77. package/dist/controllers/form_validation_controller.js.map +1 -1
  78. package/dist/controllers/frame_loading_controller.d.ts +5 -1
  79. package/dist/controllers/frame_loading_controller.js +2 -1
  80. package/dist/controllers/frame_loading_controller.js.map +1 -1
  81. package/dist/controllers/highlight_controller.d.ts +2 -0
  82. package/dist/controllers/highlight_controller.js.map +1 -1
  83. package/dist/controllers/idle_controller.d.ts +2 -0
  84. package/dist/controllers/idle_controller.js.map +1 -1
  85. package/dist/controllers/input_mask_controller.d.ts +43 -15
  86. package/dist/controllers/input_mask_controller.js +251 -76
  87. package/dist/controllers/input_mask_controller.js.map +1 -1
  88. package/dist/controllers/lazy_frame_controller.d.ts +2 -0
  89. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  90. package/dist/controllers/listbox_controller.d.ts +2 -0
  91. package/dist/controllers/listbox_controller.js.map +1 -1
  92. package/dist/controllers/local_time_controller.d.ts +2 -0
  93. package/dist/controllers/local_time_controller.js.map +1 -1
  94. package/dist/controllers/masonry_controller.d.ts +2 -0
  95. package/dist/controllers/masonry_controller.js.map +1 -1
  96. package/dist/controllers/meter_controller.d.ts +2 -0
  97. package/dist/controllers/meter_controller.js.map +1 -1
  98. package/dist/controllers/multi_select_controller.d.ts +2 -0
  99. package/dist/controllers/multi_select_controller.js.map +1 -1
  100. package/dist/controllers/nested_form_controller.d.ts +52 -13
  101. package/dist/controllers/nested_form_controller.js +450 -42
  102. package/dist/controllers/nested_form_controller.js.map +1 -1
  103. package/dist/controllers/network_status_controller.d.ts +2 -0
  104. package/dist/controllers/network_status_controller.js.map +1 -1
  105. package/dist/controllers/number_input_controller.d.ts +2 -0
  106. package/dist/controllers/number_input_controller.js +47 -5
  107. package/dist/controllers/number_input_controller.js.map +1 -1
  108. package/dist/controllers/otp_controller.d.ts +62 -23
  109. package/dist/controllers/otp_controller.js +472 -114
  110. package/dist/controllers/otp_controller.js.map +1 -1
  111. package/dist/controllers/overflow_indicator_controller.d.ts +2 -0
  112. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  113. package/dist/controllers/overflow_menu_controller.d.ts +2 -0
  114. package/dist/controllers/overflow_menu_controller.js +2 -1
  115. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  116. package/dist/controllers/pagination_controller.d.ts +2 -0
  117. package/dist/controllers/pagination_controller.js +2 -1
  118. package/dist/controllers/pagination_controller.js.map +1 -1
  119. package/dist/controllers/password_reveal_controller.d.ts +2 -0
  120. package/dist/controllers/password_reveal_controller.js.map +1 -1
  121. package/dist/controllers/password_strength_controller.d.ts +2 -0
  122. package/dist/controllers/password_strength_controller.js.map +1 -1
  123. package/dist/controllers/persist_controller.d.ts +30 -16
  124. package/dist/controllers/persist_controller.js +432 -122
  125. package/dist/controllers/persist_controller.js.map +1 -1
  126. package/dist/controllers/pointer_drag_controller.d.ts +3 -0
  127. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  128. package/dist/controllers/popover_controller.js +77 -4
  129. package/dist/controllers/popover_controller.js.map +1 -1
  130. package/dist/controllers/portal_controller.d.ts +2 -0
  131. package/dist/controllers/portal_controller.js.map +1 -1
  132. package/dist/controllers/preview_guard_controller.d.ts +2 -0
  133. package/dist/controllers/preview_guard_controller.js.map +1 -1
  134. package/dist/controllers/progress_controller.d.ts +3 -0
  135. package/dist/controllers/progress_controller.js.map +1 -1
  136. package/dist/controllers/radio_group_controller.d.ts +3 -0
  137. package/dist/controllers/radio_group_controller.js.map +1 -1
  138. package/dist/controllers/rating_controller.d.ts +2 -0
  139. package/dist/controllers/rating_controller.js +9 -5
  140. package/dist/controllers/rating_controller.js.map +1 -1
  141. package/dist/controllers/reading_progress_controller.d.ts +2 -0
  142. package/dist/controllers/reading_progress_controller.js.map +1 -1
  143. package/dist/controllers/resizable_controller.d.ts +2 -0
  144. package/dist/controllers/resizable_controller.js.map +1 -1
  145. package/dist/controllers/roving_controller.d.ts +2 -0
  146. package/dist/controllers/roving_controller.js.map +1 -1
  147. package/dist/controllers/scroll_area_controller.d.ts +23 -15
  148. package/dist/controllers/scroll_area_controller.js +462 -162
  149. package/dist/controllers/scroll_area_controller.js.map +1 -1
  150. package/dist/controllers/scroll_visibility_controller.d.ts +2 -0
  151. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  152. package/dist/controllers/scrollspy_controller.d.ts +2 -0
  153. package/dist/controllers/scrollspy_controller.js.map +1 -1
  154. package/dist/controllers/separator_controller.d.ts +54 -11
  155. package/dist/controllers/separator_controller.js +354 -38
  156. package/dist/controllers/separator_controller.js.map +1 -1
  157. package/dist/controllers/sidebar_controller.js +83 -10
  158. package/dist/controllers/sidebar_controller.js.map +1 -1
  159. package/dist/controllers/skeleton_controller.d.ts +2 -0
  160. package/dist/controllers/skeleton_controller.js.map +1 -1
  161. package/dist/controllers/slider_controller.d.ts +2 -0
  162. package/dist/controllers/slider_controller.js.map +1 -1
  163. package/dist/controllers/smart_sticky_header_controller.d.ts +2 -0
  164. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  165. package/dist/controllers/spinner_controller.d.ts +4 -1
  166. package/dist/controllers/spinner_controller.js +7 -5
  167. package/dist/controllers/spinner_controller.js.map +1 -1
  168. package/dist/controllers/step_indicator_controller.d.ts +2 -0
  169. package/dist/controllers/step_indicator_controller.js.map +1 -1
  170. package/dist/controllers/stepper_controller.d.ts +2 -0
  171. package/dist/controllers/stepper_controller.js.map +1 -1
  172. package/dist/controllers/stick_to_bottom_controller.d.ts +2 -0
  173. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  174. package/dist/controllers/sticky_observer_controller.d.ts +2 -0
  175. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  176. package/dist/controllers/submit_once_controller.d.ts +98 -39
  177. package/dist/controllers/submit_once_controller.js +402 -122
  178. package/dist/controllers/submit_once_controller.js.map +1 -1
  179. package/dist/controllers/switch_controller.d.ts +2 -0
  180. package/dist/controllers/switch_controller.js.map +1 -1
  181. package/dist/controllers/tags_input_controller.d.ts +2 -0
  182. package/dist/controllers/tags_input_controller.js.map +1 -1
  183. package/dist/controllers/textarea_autosize_controller.d.ts +31 -9
  184. package/dist/controllers/textarea_autosize_controller.js +131 -3
  185. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  186. package/dist/controllers/theme_controller.d.ts +2 -0
  187. package/dist/controllers/theme_controller.js +8 -6
  188. package/dist/controllers/theme_controller.js.map +1 -1
  189. package/dist/controllers/time_picker_controller.d.ts +2 -0
  190. package/dist/controllers/time_picker_controller.js.map +1 -1
  191. package/dist/controllers/toast_controller.d.ts +4 -0
  192. package/dist/controllers/toast_controller.js.map +1 -1
  193. package/dist/controllers/toggle_group_controller.d.ts +2 -0
  194. package/dist/controllers/toggle_group_controller.js.map +1 -1
  195. package/dist/controllers/toolbar_controller.js.map +1 -1
  196. package/dist/controllers/transition_controller.d.ts +2 -0
  197. package/dist/controllers/transition_controller.js.map +1 -1
  198. package/dist/controllers/tree_view_controller.d.ts +2 -0
  199. package/dist/controllers/tree_view_controller.js +2 -1
  200. package/dist/controllers/tree_view_controller.js.map +1 -1
  201. package/dist/index.js +4293 -1447
  202. package/dist/index.js.map +1 -1
  203. package/dist/inspector/cli.d.ts +115 -4
  204. package/dist/inspector/cli.js +195 -15
  205. package/dist/inspector/cli.js.map +1 -1
  206. package/dist/inspector/cli_bin.js +257 -47
  207. package/dist/inspector/cli_bin.js.map +1 -1
  208. package/dist/inspector/examples.json +11 -11
  209. package/dist/inspector/manifest.json +615 -66
  210. package/dist/positioning/index.d.ts +2 -0
  211. package/dist/positioning/index.js.map +1 -1
  212. package/package.json +2 -2
@@ -9,8 +9,80 @@ function setDefaultAttribute(element, name, value) {
9
9
  return true;
10
10
  }
11
11
 
12
- // src/utils/focus_trap.ts
13
- var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
12
+ // src/utils/focus_candidate.ts
13
+ function inheritsFieldsetDisabled(control) {
14
+ let fieldset = control.closest("fieldset[disabled]");
15
+ while (fieldset) {
16
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
17
+ if (!legend?.contains(control)) return true;
18
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
19
+ }
20
+ return false;
21
+ }
22
+ function canTakeFocus(element) {
23
+ if (element.closest("[hidden], [inert]")) return false;
24
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
25
+ if (!("disabled" in element)) return true;
26
+ if (element.disabled) return false;
27
+ return !inheritsFieldsetDisabled(element);
28
+ }
29
+ var TAB_STOP_CANDIDATE_SELECTOR = [
30
+ "a[href]",
31
+ "area[href]",
32
+ "button",
33
+ "input",
34
+ "select",
35
+ "textarea",
36
+ "summary",
37
+ "iframe",
38
+ "audio[controls]",
39
+ "video[controls]",
40
+ "[tabindex]",
41
+ "[contenteditable]"
42
+ ].join(",");
43
+ function isRenderedForFocus(element) {
44
+ const check = element.checkVisibility;
45
+ return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
46
+ }
47
+ function authoredTabindex(element) {
48
+ const value = element.getAttribute("tabindex");
49
+ if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
50
+ return Number(value);
51
+ }
52
+ function hasNativeTabStop(element) {
53
+ if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
54
+ return element.hasAttribute("href");
55
+ }
56
+ if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
57
+ return true;
58
+ }
59
+ if (element instanceof HTMLInputElement) return element.type !== "hidden";
60
+ if (element instanceof HTMLIFrameElement) return true;
61
+ if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
62
+ return element.hasAttribute("controls");
63
+ }
64
+ if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
65
+ const details = element.parentElement;
66
+ return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
67
+ }
68
+ return false;
69
+ }
70
+ function hasEditableTabStop(element) {
71
+ const value = element.getAttribute("contenteditable")?.toLowerCase();
72
+ return value === "" || value === "true" || value === "plaintext-only";
73
+ }
74
+ function isTabStop(element) {
75
+ if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
76
+ const tabindex = authoredTabindex(element);
77
+ if (tabindex !== null) return tabindex >= 0;
78
+ return hasNativeTabStop(element) || hasEditableTabStop(element);
79
+ }
80
+ function firstTabStop(root) {
81
+ for (const candidate of root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)) {
82
+ if (isTabStop(candidate)) return candidate;
83
+ }
84
+ return null;
85
+ }
14
86
 
15
87
  // src/controllers/form_validation_controller.ts
16
88
  var CONSTRAINT_MESSAGE_KEYS = [
@@ -24,10 +96,12 @@ var CONSTRAINT_MESSAGE_KEYS = [
24
96
  ["stepMismatch", "step-mismatch"],
25
97
  ["badInput", "bad-input"]
26
98
  ];
27
- var MESSAGE_ATTR_PREFIX = "data-stimeo--form-field-message-";
28
- var MESSAGE_ATTR_GENERIC = "data-stimeo--form-field-message";
29
- var DISALLOW_ATTR = "data-stimeo--form-field-disallow";
99
+ var MESSAGE_ATTR_PREFIX = "data-stimeo--form-validation-message-";
100
+ var MESSAGE_ATTR_GENERIC = "data-stimeo--form-validation-message";
101
+ var DISALLOW_ATTR = "data-stimeo--form-validation-disallow";
102
+ var DISALLOW_WHITESPACE_MESSAGE = `${MESSAGE_ATTR_PREFIX}whitespace`;
30
103
  var DISALLOW_WHITESPACE_DEFAULT = "Please enter a value that is not only whitespace.";
104
+ var FORM_FIELD_INVALID_ATTR = "data-stimeo--form-field-invalid";
31
105
  var FormValidationController = class _FormValidationController extends Controller {
32
106
  static outlets = ["stimeo--form-field"];
33
107
  static values = {
@@ -40,14 +114,21 @@ var FormValidationController = class _FormValidationController extends Controlle
40
114
  static events = ["valid", "invalid"];
41
115
  /** Marker recording that we added `novalidate`, so we only remove our own. */
42
116
  static #NOVALIDATE_MARKER = "data-stimeo--form-validation-novalidate";
43
- /** Controls already interacted with — the gate for blur / input (re)validation. */
44
- #touched = /* @__PURE__ */ new WeakSet();
117
+ /** Object-backed groups already interacted with — the input revalidation gate. */
118
+ #touchedGroups = /* @__PURE__ */ new WeakSet();
119
+ /** String-backed fallback radio groups already interacted with. */
120
+ #touchedRadioGroups = /* @__PURE__ */ new Set();
45
121
  /**
46
- * Controls whose `customError` *we* set via the `disallow` rule. Tracked so we
47
- * only ever clear our own custom validity — a consumer's `setCustomValidity` on
48
- * the same control survives once our rule passes (don't-clobber-authored-state).
122
+ * Last message this controller wrote through `setCustomValidity`, kept as a
123
+ * two-layer ownership ledger: a value is cleared only while the live message
124
+ * still equals the recorded write. Iterable so disconnect can release every
125
+ * surviving loan without touching a consumer's later custom error.
49
126
  */
50
- #ownedCustomError = /* @__PURE__ */ new WeakSet();
127
+ #ownedCustomErrors = /* @__PURE__ */ new Map();
128
+ /** Last invalid message routed to each field, suppressing duplicate reports. */
129
+ #reportedErrors = /* @__PURE__ */ new WeakMap();
130
+ /** Exact document that owns the delegated listeners for this connection. */
131
+ #listenerDocument = null;
51
132
  #onSubmit = (event) => {
52
133
  if (event.target !== this.element) return;
53
134
  const invalid = this.#validateAll();
@@ -63,43 +144,51 @@ var FormValidationController = class _FormValidationController extends Controlle
63
144
  };
64
145
  #onFocusOut = (event) => {
65
146
  if (!this.validateOnBlurValue) return;
66
- const control = this.#controlFrom(event.target);
67
- if (!control) return;
68
- const field = this.#fieldFor(control);
147
+ const snapshot = this.#snapshot();
148
+ const group = snapshot.groupByControl.get(this.#controlFrom(event.target));
149
+ if (!group) return;
69
150
  const related = event.relatedTarget;
70
- if (field && related instanceof Node && field.element.contains(related)) return;
71
- this.#touched.add(control);
72
- this.#validateControl(control);
151
+ if (group.field && related instanceof Node && group.field.element.contains(related)) return;
152
+ if (snapshot.groupByControl.get(this.#controlFrom(related))?.key === group.key) return;
153
+ this.#markTouched(group.key);
154
+ this.#applyGroup(group);
73
155
  };
74
156
  #onInput = (event) => {
75
157
  if (!this.revalidateOnInputValue) return;
76
- const control = this.#controlFrom(event.target);
77
- if (!control || !this.#touched.has(control)) return;
78
- this.#validateControl(control);
158
+ const group = this.#snapshot().groupByControl.get(this.#controlFrom(event.target));
159
+ if (!group || !this.#isTouched(group.key)) return;
160
+ this.#applyGroup(group);
79
161
  };
80
162
  #onChange = (event) => {
81
163
  if (!this.validateOnChangeValue) return;
82
- const control = this.#controlFrom(event.target);
83
- if (!control) return;
84
- this.#touched.add(control);
85
- this.#validateControl(control);
164
+ const group = this.#snapshot().groupByControl.get(this.#controlFrom(event.target));
165
+ if (!group) return;
166
+ this.#markTouched(group.key);
167
+ this.#applyGroup(group);
86
168
  };
87
169
  /** Suppresses native bubbles and binds the submit / blur / input listeners. */
88
170
  connect() {
89
171
  if (setDefaultAttribute(this.element, "novalidate", "")) {
90
172
  this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
91
173
  }
92
- document.addEventListener("submit", this.#onSubmit, true);
93
- this.element.addEventListener("focusout", this.#onFocusOut);
94
- this.element.addEventListener("input", this.#onInput);
95
- this.element.addEventListener("change", this.#onChange);
174
+ this.#listenerDocument = this.element.ownerDocument;
175
+ this.#listenerDocument.addEventListener("submit", this.#onSubmit, true);
176
+ this.#listenerDocument.addEventListener("focusout", this.#onFocusOut);
177
+ this.#listenerDocument.addEventListener("input", this.#onInput);
178
+ this.#listenerDocument.addEventListener("change", this.#onChange);
96
179
  }
97
180
  /** Tears down listeners and restores `novalidate` if we added it. */
98
181
  disconnect() {
99
- document.removeEventListener("submit", this.#onSubmit, true);
100
- this.element.removeEventListener("focusout", this.#onFocusOut);
101
- this.element.removeEventListener("input", this.#onInput);
102
- this.element.removeEventListener("change", this.#onChange);
182
+ this.#listenerDocument?.removeEventListener("submit", this.#onSubmit, true);
183
+ this.#listenerDocument?.removeEventListener("focusout", this.#onFocusOut);
184
+ this.#listenerDocument?.removeEventListener("input", this.#onInput);
185
+ this.#listenerDocument?.removeEventListener("change", this.#onChange);
186
+ this.#listenerDocument = null;
187
+ for (const [control, message] of this.#ownedCustomErrors) {
188
+ if (control.validationMessage === message) control.setCustomValidity("");
189
+ }
190
+ this.#ownedCustomErrors.clear();
191
+ this.#touchedRadioGroups.clear();
103
192
  if (this.element.hasAttribute(_FormValidationController.#NOVALIDATE_MARKER)) {
104
193
  this.element.removeAttribute("novalidate");
105
194
  this.element.removeAttribute(_FormValidationController.#NOVALIDATE_MARKER);
@@ -107,8 +196,8 @@ var FormValidationController = class _FormValidationController extends Controlle
107
196
  }
108
197
  /**
109
198
  * Validates every control now, rendering or clearing each field's message, and
110
- * returns whether the whole form is valid. Marks every control touched so a
111
- * later input re-validates it. Bound via `data-action`
199
+ * returns whether the whole form is valid. Marks every field/group touched so
200
+ * a later input from any sibling re-validates it. Bound via `data-action`
112
201
  * (`#validate`) or callable directly (e.g. before a programmatic submit).
113
202
  */
114
203
  validate() {
@@ -122,33 +211,42 @@ var FormValidationController = class _FormValidationController extends Controlle
122
211
  * Each group's first invalid control supplies the message and the focus target.
123
212
  */
124
213
  #validateAll() {
214
+ const invalid = [];
215
+ for (const group of this.#snapshot().groups.values()) {
216
+ this.#markTouched(group.key);
217
+ const firstInvalid = this.#applyGroup(group);
218
+ if (firstInvalid) invalid.push(firstInvalid);
219
+ }
220
+ return invalid;
221
+ }
222
+ /** Builds the current field table in one pass over form controls and DOM depth. */
223
+ #snapshot() {
224
+ const fields = this.#fieldOutlets();
125
225
  const groups = /* @__PURE__ */ new Map();
226
+ const groupByControl = /* @__PURE__ */ new Map();
126
227
  for (const control of this.#controls) {
127
- this.#touched.add(control);
128
- const field = this.#fieldFor(control);
228
+ const field = this.#fieldFor(control, fields);
129
229
  const key = this.#keyFor(control, field);
130
- const group = groups.get(key);
131
- if (group) {
132
- group.controls.push(control);
133
- } else {
134
- groups.set(key, { field, controls: [control] });
230
+ let group = groups.get(key);
231
+ if (!group) {
232
+ group = { key, field, controls: [] };
233
+ groups.set(key, group);
135
234
  }
235
+ group.controls.push(control);
236
+ groupByControl.set(control, group);
136
237
  }
137
- const invalid = [];
138
- for (const group of groups.values()) {
139
- const firstInvalid = this.#applyGroup(group);
140
- if (firstInvalid) invalid.push(firstInvalid);
238
+ return { groups, groupByControl };
239
+ }
240
+ /** Records that a whole field/group, rather than one sibling control, was visited. */
241
+ #markTouched(key) {
242
+ if (typeof key === "string") {
243
+ this.#touchedRadioGroups.add(key);
244
+ } else {
245
+ this.#touchedGroups.add(key);
141
246
  }
142
- return invalid;
143
247
  }
144
- /** Re-validates the whole field a single control belongs to (or that control). */
145
- #validateControl(control) {
146
- const field = this.#fieldFor(control);
147
- const key = this.#keyFor(control, field);
148
- const controls = this.#controls.filter(
149
- (other) => this.#keyFor(other, this.#fieldFor(other)) === key
150
- );
151
- this.#applyGroup({ field, controls });
248
+ #isTouched(key) {
249
+ return typeof key === "string" ? this.#touchedRadioGroups.has(key) : this.#touchedGroups.has(key);
152
250
  }
153
251
  /**
154
252
  * Runs native constraint validation across a field's controls and routes the
@@ -162,51 +260,63 @@ var FormValidationController = class _FormValidationController extends Controlle
162
260
  const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;
163
261
  if (group.field) {
164
262
  if (firstInvalid) {
165
- group.field.setError(this.#messageFor(firstInvalid));
166
- } else {
263
+ const message = this.#messageFor(firstInvalid);
264
+ const alreadyReported = group.field.element.hasAttribute(FORM_FIELD_INVALID_ATTR) && this.#reportedErrors.get(group.field) === message;
265
+ if (!alreadyReported) group.field.setError(message, { focus: false });
266
+ this.#reportedErrors.set(group.field, message);
267
+ } else if (group.field.element.hasAttribute(FORM_FIELD_INVALID_ATTR) || this.#reportedErrors.has(group.field)) {
167
268
  group.field.clearError();
269
+ this.#reportedErrors.delete(group.field);
168
270
  }
169
271
  }
170
272
  return firstInvalid;
171
273
  }
172
274
  /**
173
275
  * Resolves the message to show for an invalid control: a per-constraint
174
- * override (`data-stimeo--form-field-message-<constraint>`) for the first failing
175
- * `ValidityState` flag, then a generic `data-stimeo--form-field-message`
276
+ * override (`data-stimeo--form-validation-message-<constraint>`) for the first failing
277
+ * `ValidityState` flag, then a generic `data-stimeo--form-validation-message`
176
278
  * override, then the browser's native `validationMessage`. Authoring an override
177
279
  * gives controlled, localizable, theme-able wording with **no consumer JS** —
178
280
  * and sidesteps headless browsers that return an empty native message.
179
281
  */
180
282
  #messageFor(control) {
283
+ if (control.validity.customError) return control.validationMessage;
181
284
  for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {
182
285
  if (control.validity[flag]) {
183
- return control.getAttribute(`${MESSAGE_ATTR_PREFIX}${key}`) ?? control.getAttribute(MESSAGE_ATTR_GENERIC) ?? control.validationMessage;
286
+ return this.#authoredMessage(control, `${MESSAGE_ATTR_PREFIX}${key}`) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? control.validationMessage;
184
287
  }
185
288
  }
186
- return control.validationMessage || control.getAttribute(MESSAGE_ATTR_GENERIC) || "";
289
+ return control.validationMessage || this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) || "";
187
290
  }
188
291
  /**
189
292
  * Applies (or clears) a declarative custom constraint via `setCustomValidity`,
190
- * for controls that opt in with `data-stimeo--form-field-disallow`. The supported
293
+ * for controls that opt in with `data-stimeo--form-validation-disallow`. The supported
191
294
  * rule is `"whitespace"` — a value that is non-empty but blank
192
295
  * after trimming (which slips past `required` / `minlength`); its message follows
193
- * the per-constraint (`value-missing`) → generic → default chain.
296
+ * the whitespace-specific → generic → default chain.
194
297
  *
195
- * Don't-clobber-authored-state: an unknown/absent rule is never touched, and a
196
- * custom error is only cleared when *we* set it (tracked in {@link #ownedCustomError}),
197
- * so a consumer's own `setCustomValidity` on the same control survives.
298
+ * Don't-clobber-authored-state: an unknown/absent rule is never written, an
299
+ * existing consumer custom error wins, and a controller error is only cleared
300
+ * while the live message still equals the recorded controller write.
198
301
  */
199
302
  #syncCustomValidity(control) {
303
+ const recorded = this.#ownedCustomErrors.get(control);
304
+ const ownsCurrent = recorded !== void 0 && control.validationMessage === recorded;
200
305
  const violates = control.getAttribute(DISALLOW_ATTR) === "whitespace" && control.value.length > 0 && control.value.trim() === "";
201
- if (violates) {
202
- control.setCustomValidity(
203
- control.getAttribute(`${MESSAGE_ATTR_PREFIX}value-missing`) ?? control.getAttribute(MESSAGE_ATTR_GENERIC) ?? DISALLOW_WHITESPACE_DEFAULT
204
- );
205
- this.#ownedCustomError.add(control);
206
- } else if (this.#ownedCustomError.has(control)) {
207
- this.#ownedCustomError.delete(control);
208
- control.setCustomValidity("");
306
+ if (!violates) {
307
+ if (ownsCurrent) control.setCustomValidity("");
308
+ this.#ownedCustomErrors.delete(control);
309
+ return;
209
310
  }
311
+ if (control.validity.customError && !ownsCurrent) return;
312
+ const message = this.#authoredMessage(control, DISALLOW_WHITESPACE_MESSAGE) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? DISALLOW_WHITESPACE_DEFAULT;
313
+ if (!ownsCurrent || recorded !== message) control.setCustomValidity(message);
314
+ this.#ownedCustomErrors.set(control, message);
315
+ }
316
+ /** Returns a non-blank authored message, otherwise falls through to a fallback. */
317
+ #authoredMessage(control, attribute) {
318
+ const message = control.getAttribute(attribute);
319
+ return message && message.trim().length > 0 ? message : null;
210
320
  }
211
321
  /**
212
322
  * A grouping key that collects controls belonging to the same field: the owning
@@ -230,18 +340,32 @@ var FormValidationController = class _FormValidationController extends Controlle
230
340
  * is deterministic and CSS-independent.
231
341
  */
232
342
  #focusTargetFor(control) {
233
- if (!control.hidden) return control;
234
- const field = this.#fieldFor(control);
343
+ if (canTakeFocus(control)) return control;
344
+ const field = this.#fieldFor(control, this.#fieldOutlets());
235
345
  if (!field?.hasControlTarget) return null;
236
346
  const root = field.controlTarget;
237
- if (root.matches(FOCUSABLE)) return root;
238
- return root.querySelector(FOCUSABLE);
347
+ if (isTabStop(root)) return root;
348
+ return firstTabStop(root);
239
349
  }
240
- /** The `stimeo--form-field` outlet whose element contains `control`, if any. */
241
- #fieldFor(control) {
350
+ /** Pairs each live outlet element with its controller once per operation. */
351
+ #fieldOutlets() {
352
+ const fields = /* @__PURE__ */ new Map();
242
353
  const elements = this.stimeoFormFieldOutletElements;
354
+ const outlets = this.stimeoFormFieldOutlets;
243
355
  for (let index = 0; index < elements.length; index++) {
244
- if (elements[index]?.contains(control)) return this.stimeoFormFieldOutlets[index];
356
+ const element = elements[index];
357
+ const outlet = outlets[index];
358
+ if (element && outlet) fields.set(element, outlet);
359
+ }
360
+ return fields;
361
+ }
362
+ /** The nearest configured field ancestor of `control`, if any. */
363
+ #fieldFor(control, fields) {
364
+ let ancestor = control;
365
+ while (ancestor) {
366
+ const field = fields.get(ancestor);
367
+ if (field) return field;
368
+ ancestor = ancestor.parentElement;
245
369
  }
246
370
  return void 0;
247
371
  }
@@ -255,11 +379,12 @@ var FormValidationController = class _FormValidationController extends Controlle
255
379
  }
256
380
  /** Narrows an event target to a validatable control. */
257
381
  #controlFrom(target) {
258
- return target instanceof Element && this.#isValidatable(target) ? target : null;
382
+ return target instanceof Element && this.#isValidatable(target) && target.form === this.element ? target : null;
259
383
  }
260
384
  #isValidatable(element) {
261
- return (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && // `willValidate` already excludes disabled, read-only, hidden, and button
262
- // controls — the exact set barred from constraint validation.
385
+ return (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && // `willValidate` already excludes disabled, read-only, input[type=hidden],
386
+ // and button-type controls. The `hidden` attribute intentionally does not
387
+ // bar a text mirror from constraint validation.
263
388
  element.willValidate;
264
389
  }
265
390
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/utils/default_attribute.ts","../../src/utils/focus_trap.ts","../../src/controllers/form_validation_controller.ts"],"names":[],"mappings":"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","file":"form_validation_controller.js","sourcesContent":["/**\n * Adds an attribute default without displacing an authored value.\n *\n * Attribute presence — including an authored empty string — is the ownership\n * boundary. The return value lets a caller remember that it supplied the\n * default when that caller must later restore the authored state.\n *\n * @param element - Element that owns the attribute\n * @param name - Attribute name\n * @param value - Value to write only when the attribute is absent\n * @returns Whether this call added the attribute\n */\nexport function setDefaultAttribute(element: Element, name: string, value: string): boolean {\n if (element.hasAttribute(name)) return false;\n element.setAttribute(name, value);\n return true;\n}\n","import { BeforeCacheReset } from \"./before_cache_reset\";\nimport { EscapeLayer } from \"./escape_layer\";\n\n/**\n * Modal focus-trap primitive shared by the modal-overlay controllers\n * (dialog / alert-dialog / confirm / drawer / command-palette / sidebar); the\n * non-modal focus scope (focus) reuses it with the modal side effects opted out.\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) and kept out of the snapshot Turbo\n * caches (`turbo:before-cache`). {@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 joins the Escape stack). Dismissal\n * is resolved by the shared {@link EscapeLayer}: an Escape already consumed\n * by an inner handler is ignored, and among active layers the most recently\n * activated claiming one owns the press. The resolver consumes the event\n * before invoking the callback.\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 /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */\n readonly #escapeLayer = new EscapeLayer();\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 this.#beforeCache.activate();\n const onEscape = this.#options.onEscape;\n if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });\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 this.#escapeLayer.deactivate();\n document.removeEventListener(\"keydown\", this.#onKeydown);\n this.#beforeCache.deactivate();\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 /**\n * Reverts the side effects just before Turbo caches the page snapshot, so an\n * overlay left open does not bake the scroll lock into `body[style]` — a\n * restored page would feed that locked value back into {@link activate} as the\n * baseline, and closing would then never unlock the page. Markup state stays\n * untouched (restore-open designs reopen against a clean baseline), and focus\n * is left alone mid-navigation. The listener lives only while active.\n */\n readonly #beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));\n\n /**\n * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the\n * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of\n * which layer currently owns Escape.\n */\n readonly #onKeydown = (event: KeyboardEvent): void => {\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 { setDefaultAttribute } from \"../utils/default_attribute\";\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/**\n * Maps each `ValidityState` flag (in priority order) to the kebab-case suffix of\n * its per-constraint message-override attribute. The first flag that is `true`\n * wins, so e.g. `valueMissing` is reported before a stale `patternMismatch`.\n */\nconst CONSTRAINT_MESSAGE_KEYS: ReadonlyArray<readonly [keyof ValidityState, string]> = [\n [\"valueMissing\", \"value-missing\"],\n [\"typeMismatch\", \"type-mismatch\"],\n [\"patternMismatch\", \"pattern-mismatch\"],\n [\"tooShort\", \"too-short\"],\n [\"tooLong\", \"too-long\"],\n [\"rangeUnderflow\", \"range-underflow\"],\n [\"rangeOverflow\", \"range-overflow\"],\n [\"stepMismatch\", \"step-mismatch\"],\n [\"badInput\", \"bad-input\"],\n];\n\n/** Attribute prefix for a per-constraint message override, authored on the control. */\nconst MESSAGE_ATTR_PREFIX = \"data-stimeo--form-field-message-\";\n/** Attribute for a generic message override applied to any failing constraint. */\nconst MESSAGE_ATTR_GENERIC = \"data-stimeo--form-field-message\";\n/** Attribute opting a control into a declarative custom rule (`\"whitespace\"`). */\nconst DISALLOW_ATTR = \"data-stimeo--form-field-disallow\";\n/** Default message when `disallow=\"whitespace\"` fails and no override is given. */\nconst DISALLOW_WHITESPACE_DEFAULT = \"Please enter a value that is not only whitespace.\";\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 * It sets the form's `novalidate` so it can replace the browser's default error\n * bubbles with the accessible, in-page `role=\"alert\"` regions, and restores the\n * attribute on disconnect.\n *\n * Two declarative escape hatches let a field **exceed** native validation with no\n * consumer JS (author them on the control):\n * - **Per-constraint messages** — `data-stimeo--form-field-message-<constraint>`\n * (`value-missing`, `too-short`, `too-long`, `pattern-mismatch`, `type-mismatch`,\n * `range-overflow`, `range-underflow`, `step-mismatch`, `bad-input`), or a generic\n * `data-stimeo--form-field-message` fallback, override the shown text per failing\n * `ValidityState` flag — controlled, localizable wording that also fixes headless\n * browsers returning an empty native `validationMessage`. Falls back to native.\n * - **`data-stimeo--form-field-disallow=\"whitespace\"`** — a built-in custom rule\n * rejecting a value that is blank after trimming (which slips past `required` /\n * `minlength`), wired through `setCustomValidity` so it blocks submit like any\n * native constraint.\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 /**\n * Controls whose `customError` *we* set via the `disallow` rule. Tracked so we\n * only ever clear our own custom validity — a consumer's `setCustomValidity` on\n * the same control survives once our rule passes (don't-clobber-authored-state).\n */\n readonly #ownedCustomError = 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 (setDefaultAttribute(this.element, \"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 // Apply declarative custom rules (e.g. disallow=\"whitespace\") before reading\n // validity so they participate in checkValidity() like a native constraint.\n for (const control of group.controls) this.#syncCustomValidity(control);\n const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;\n if (group.field) {\n if (firstInvalid) {\n group.field.setError(this.#messageFor(firstInvalid));\n } else {\n group.field.clearError();\n }\n }\n return firstInvalid;\n }\n\n /**\n * Resolves the message to show for an invalid control: a per-constraint\n * override (`data-stimeo--form-field-message-<constraint>`) for the first failing\n * `ValidityState` flag, then a generic `data-stimeo--form-field-message`\n * override, then the browser's native `validationMessage`. Authoring an override\n * gives controlled, localizable, theme-able wording with **no consumer JS** —\n * and sidesteps headless browsers that return an empty native message.\n */\n #messageFor(control: ValidatableControl): string {\n for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {\n if (control.validity[flag]) {\n return (\n control.getAttribute(`${MESSAGE_ATTR_PREFIX}${key}`) ??\n control.getAttribute(MESSAGE_ATTR_GENERIC) ??\n control.validationMessage\n );\n }\n }\n // customError (our `disallow` rule, or a consumer's setCustomValidity): for our\n // rule the message was already resolved with per-constraint > generic > default\n // precedence when set, and a consumer error carries its own text — so return the\n // live validationMessage as-is, falling back to the generic override then \"\".\n return control.validationMessage || control.getAttribute(MESSAGE_ATTR_GENERIC) || \"\";\n }\n\n /**\n * Applies (or clears) a declarative custom constraint via `setCustomValidity`,\n * for controls that opt in with `data-stimeo--form-field-disallow`. The supported\n * rule is `\"whitespace\"` — a value that is non-empty but blank\n * after trimming (which slips past `required` / `minlength`); its message follows\n * the per-constraint (`value-missing`) → generic → default chain.\n *\n * Don't-clobber-authored-state: an unknown/absent rule is never touched, and a\n * custom error is only cleared when *we* set it (tracked in {@link #ownedCustomError}),\n * so a consumer's own `setCustomValidity` on the same control survives.\n */\n #syncCustomValidity(control: ValidatableControl): void {\n const violates =\n control.getAttribute(DISALLOW_ATTR) === \"whitespace\" &&\n control.value.length > 0 &&\n control.value.trim() === \"\";\n if (violates) {\n control.setCustomValidity(\n control.getAttribute(`${MESSAGE_ATTR_PREFIX}value-missing`) ??\n control.getAttribute(MESSAGE_ATTR_GENERIC) ??\n DISALLOW_WHITESPACE_DEFAULT,\n );\n this.#ownedCustomError.add(control);\n } else if (this.#ownedCustomError.has(control)) {\n // Only clear the custom error this controller set; leave a consumer's intact.\n this.#ownedCustomError.delete(control);\n control.setCustomValidity(\"\");\n }\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 — the case 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"]}
1
+ {"version":3,"sources":["../../src/utils/default_attribute.ts","../../src/utils/focus_candidate.ts","../../src/controllers/form_validation_controller.ts"],"names":[],"mappings":"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","file":"form_validation_controller.js","sourcesContent":["/**\n * Adds an attribute default without displacing an authored value.\n *\n * Attribute presence — including an authored empty string — is the ownership\n * boundary. The return value lets a caller remember that it supplied the\n * default when that caller must later restore the authored state.\n *\n * @param element - Element that owns the attribute\n * @param name - Attribute name\n * @param value - Value to write only when the attribute is absent\n * @returns Whether this call added the attribute\n */\nexport function setDefaultAttribute(element: Element, name: string, value: string): boolean {\n if (element.hasAttribute(name)) return false;\n element.setAttribute(name, value);\n return true;\n}\n","/**\n * Whether a disabled `<fieldset>` ancestor actually reaches `control`.\n *\n * HTML exempts the contents of a fieldset's **first direct-child `<legend>`**, and\n * the exemption is per fieldset — a control legal in one legend can still be\n * disabled by a second, outer fieldset, so the walk continues upward.\n *\n * Exported for the consumer that needs this rule *without* the rest of\n * {@link canTakeFocus}: `toolbar` bounds its own `hidden` walk at the toolbar\n * root, which is a different rule, so it composes the two itself.\n */\nexport function inheritsFieldsetDisabled(control: HTMLElement): boolean {\n let fieldset: Element | null = control.closest(\"fieldset[disabled]\");\n while (fieldset) {\n const legend = Array.from(fieldset.children).find((child) => child.tagName === \"LEGEND\");\n if (!legend?.contains(control)) return true;\n fieldset = fieldset.parentElement?.closest(\"fieldset[disabled]\") ?? null;\n }\n return false;\n}\n\n/**\n * Whether an element can actually take focus, checked **before** `focus()` runs.\n *\n * A controller that must move focus off a control it is about to disable or hide\n * picks a destination and calls `focus()` on it. If that destination cannot take\n * focus, the call **fails silently**: `hidden` and natively `disabled` elements\n * swallow it, so the caret stays in the subtree that is disappearing and lands on\n * `<body>` a frame later — the exact outcome the rescue exists to prevent, minus\n * any signal that it happened.\n *\n * Testing after the fact is the obvious alternative and is deliberately not the\n * rule. Reading `document.activeElement` back only works in a real browser, and\n * looping over candidates that way performs a real focus move per failure —\n * observable to assistive technology. Checking first costs nothing and catches\n * the cases that actually occur.\n *\n * **`aria-disabled` is not disqualifying.** It is the attribute an author uses\n * for a control that must stay *discoverable*, and the roving contract keeps\n * such items reachable. Only the three conditions that make the platform refuse\n * focus are checked: `hidden` / `inert` (an ancestor's counts),\n * `input[type=\"hidden\"]`, the native `disabled` property, and `disabled`\n * inherited from an ancestor `fieldset`. CSS-only invisibility is handled by\n * {@link isRenderedForFocus} when a consumer needs sequential-focus semantics.\n *\n * Reading `:disabled` instead of walking the fieldset chain would be shorter, but\n * that pseudo-class is not evaluated consistently outside real browsers and this\n * has to be right headlessly too. happy-dom in particular focuses a `<button>`\n * inside a disabled fieldset where a real engine refuses, so the inheritance is\n * spelled out rather than delegated.\n *\n * What a consumer does when nothing survives is its own call: some fall back to\n * their landmark, while a widget whose caret already sits somewhere legitimate\n * refuses the move outright rather than relocating it.\n *\n * @example\n * ```ts\n * const target = candidates.find(canTakeFocus);\n * if (target) target.focus();\n * else {\n * this.#tabindex.lend(this.element); // nothing left: fall back to the landmark\n * this.element.focus();\n * }\n * ```\n *\n * @param element - the candidate destination\n */\nexport function canTakeFocus(element: HTMLElement): boolean {\n if (element.closest(\"[hidden], [inert]\")) return false;\n if (element instanceof HTMLInputElement && element.type === \"hidden\") return false;\n if (!(\"disabled\" in element)) return true;\n if ((element as HTMLElement & { disabled: boolean }).disabled) return false;\n return !inheritsFieldsetDisabled(element);\n}\n\n/** Elements whose semantics or authored attributes can place them in sequential focus order. */\nexport const TAB_STOP_CANDIDATE_SELECTOR = [\n \"a[href]\",\n \"area[href]\",\n \"button\",\n \"input\",\n \"select\",\n \"textarea\",\n \"summary\",\n \"iframe\",\n \"audio[controls]\",\n \"video[controls]\",\n \"[tabindex]\",\n \"[contenteditable]\",\n].join(\",\");\n\n/** Optional browser visibility API used to exclude CSS-hidden candidates. */\ninterface VisibilityCheckable {\n checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean;\n}\n\n/** Whether CSS visibility allows an otherwise eligible element to participate in focus order. */\nexport function isRenderedForFocus(element: HTMLElement): boolean {\n const check = (element as HTMLElement & VisibilityCheckable).checkVisibility;\n return typeof check === \"function\" ? check.call(element, { visibilityProperty: true }) : true;\n}\n\n/** Parses an authored `tabindex`; invalid syntax has no explicit focus-order meaning. */\nfunction authoredTabindex(element: HTMLElement): number | null {\n const value = element.getAttribute(\"tabindex\");\n if (value === null || !/^[+-]?\\d+$/.test(value.trim())) return null;\n return Number(value);\n}\n\n/** Whether the element's native semantics place it in sequential focus order. */\nfunction hasNativeTabStop(element: HTMLElement): boolean {\n if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {\n return element.hasAttribute(\"href\");\n }\n if (\n element instanceof HTMLButtonElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement\n ) {\n return true;\n }\n if (element instanceof HTMLInputElement) return element.type !== \"hidden\";\n if (element instanceof HTMLIFrameElement) return true;\n if (element.tagName === \"AUDIO\" || element.tagName === \"VIDEO\") {\n return element.hasAttribute(\"controls\");\n }\n if (element instanceof HTMLElement && element.tagName === \"SUMMARY\") {\n const details = element.parentElement;\n return (\n details instanceof HTMLDetailsElement &&\n Array.from(details.children).find((child) => child.tagName === \"SUMMARY\") === element\n );\n }\n return false;\n}\n\n/** Whether an explicit `contenteditable` value creates an editable tab stop. */\nfunction hasEditableTabStop(element: HTMLElement): boolean {\n const value = element.getAttribute(\"contenteditable\")?.toLowerCase();\n return value === \"\" || value === \"true\" || value === \"plaintext-only\";\n}\n\n/**\n * Whether an element is a usable sequential Tab stop right now.\n *\n * Native semantics, authored `tabindex`, editable hosts, inherited disabled state,\n * HTML `hidden`/`inert`, and CSS visibility are evaluated together. `aria-disabled`\n * remains focusable because it communicates unavailability without removing the\n * control from discovery order.\n */\nexport function isTabStop(element: HTMLElement): boolean {\n if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;\n\n const tabindex = authoredTabindex(element);\n if (tabindex !== null) return tabindex >= 0;\n return hasNativeTabStop(element) || hasEditableTabStop(element);\n}\n\n/** Returns every usable sequential Tab stop below `root` in document order. */\nexport function tabStopsWithin(root: ParentNode): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)).filter(\n isTabStop,\n );\n}\n\n/** Returns the first usable sequential Tab stop below `root`, if one exists. */\nexport function firstTabStop(root: ParentNode): HTMLElement | null {\n for (const candidate of root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)) {\n if (isTabStop(candidate)) return candidate;\n }\n return null;\n}\n\n/** Whether `root` contains at least one usable sequential Tab stop. */\nexport function hasTabStop(root: ParentNode): boolean {\n return firstTabStop(root) !== null;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { setDefaultAttribute } from \"../utils/default_attribute\";\nimport { canTakeFocus, firstTabStop, isTabStop } from \"../utils/focus_candidate\";\nimport type { FormFieldController } from \"./form_field_controller\";\n\n/** Native form controls that participate in constraint validation. */\ntype ValidatableControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;\n\n/**\n * Maps each `ValidityState` flag (in priority order) to the kebab-case suffix of\n * its per-constraint message-override attribute. The first flag that is `true`\n * wins, so e.g. `valueMissing` is reported before a stale `patternMismatch`.\n */\nconst CONSTRAINT_MESSAGE_KEYS: ReadonlyArray<readonly [keyof ValidityState, string]> = [\n [\"valueMissing\", \"value-missing\"],\n [\"typeMismatch\", \"type-mismatch\"],\n [\"patternMismatch\", \"pattern-mismatch\"],\n [\"tooShort\", \"too-short\"],\n [\"tooLong\", \"too-long\"],\n [\"rangeUnderflow\", \"range-underflow\"],\n [\"rangeOverflow\", \"range-overflow\"],\n [\"stepMismatch\", \"step-mismatch\"],\n [\"badInput\", \"bad-input\"],\n];\n\n/** Attribute prefix for a per-constraint message override, authored on the control. */\nconst MESSAGE_ATTR_PREFIX = \"data-stimeo--form-validation-message-\";\n/** Attribute for a generic message override applied to any failing constraint. */\nconst MESSAGE_ATTR_GENERIC = \"data-stimeo--form-validation-message\";\n/** Attribute opting a control into a declarative custom rule (`\"whitespace\"`). */\nconst DISALLOW_ATTR = \"data-stimeo--form-validation-disallow\";\n/** Message override dedicated to the declarative whitespace rule. */\nconst DISALLOW_WHITESPACE_MESSAGE = `${MESSAGE_ATTR_PREFIX}whitespace`;\n/** Default message when `disallow=\"whitespace\"` fails and no override is given. */\nconst DISALLOW_WHITESPACE_DEFAULT = \"Please enter a value that is not only whitespace.\";\n\n/** Public invalid-state hook owned by `stimeo--form-field`. */\nconst FORM_FIELD_INVALID_ATTR = \"data-stimeo--form-field-invalid\";\n\n/** Identity used to collect controls into one validation field. */\ntype FieldGroupKey = FormFieldController | ValidatableControl | string;\n\n/** A field's validatable controls, grouped so the whole field validates together. */\ninterface FieldGroup {\n readonly key: FieldGroupKey;\n readonly field: FormFieldController | undefined;\n readonly controls: ValidatableControl[];\n}\n\n/** Live form grouping resolved once for a validation operation. */\ninterface ValidationSnapshot {\n readonly groups: Map<FieldGroupKey, FieldGroup>;\n /** `null` is a deliberate miss key so delegated non-control events need one guard. */\n readonly groupByControl: Map<ValidatableControl | null, FieldGroup>;\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 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 * `valid` dispatches `{}`; `invalid` dispatches `{ invalid: HTMLElement[] }`.\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 * It sets the form's `novalidate` so it can replace the browser's default error\n * bubbles with accessible in-page visual errors plus the shared Announcer, and\n * restores the attribute on disconnect.\n *\n * Two declarative escape hatches let a field **exceed** native validation with no\n * consumer JS (author them on the control):\n * - **Per-constraint messages** — `data-stimeo--form-validation-message-<constraint>`\n * (`value-missing`, `too-short`, `too-long`, `pattern-mismatch`, `type-mismatch`,\n * `range-overflow`, `range-underflow`, `step-mismatch`, `bad-input`), or a generic\n * `data-stimeo--form-validation-message` fallback, override the shown text per failing\n * `ValidityState` flag — controlled, localizable wording that also fixes headless\n * browsers returning an empty native `validationMessage`. Falls back to native.\n * - **`data-stimeo--form-validation-disallow=\"whitespace\"`** — a built-in custom rule\n * rejecting a value that is blank after trimming (which slips past `required` /\n * `minlength`), wired through `setCustomValidity` so it blocks submit like any\n * native constraint.\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 /** Object-backed groups already interacted with — the input revalidation gate. */\n readonly #touchedGroups = new WeakSet<FormFieldController | ValidatableControl>();\n\n /** String-backed fallback radio groups already interacted with. */\n readonly #touchedRadioGroups = new Set<string>();\n\n /**\n * Last message this controller wrote through `setCustomValidity`, kept as a\n * two-layer ownership ledger: a value is cleared only while the live message\n * still equals the recorded write. Iterable so disconnect can release every\n * surviving loan without touching a consumer's later custom error.\n */\n readonly #ownedCustomErrors = new Map<ValidatableControl, string>();\n\n /** Last invalid message routed to each field, suppressing duplicate reports. */\n readonly #reportedErrors = new WeakMap<FormFieldController, string>();\n\n /** Exact document that owns the delegated listeners for this connection. */\n #listenerDocument: Document | null = null;\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 snapshot = this.#snapshot();\n const group = snapshot.groupByControl.get(this.#controlFrom(event.target));\n if (!group) 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 related = event.relatedTarget;\n if (group.field && related instanceof Node && group.field.element.contains(related)) return;\n if (snapshot.groupByControl.get(this.#controlFrom(related))?.key === group.key) return;\n this.#markTouched(group.key);\n this.#applyGroup(group);\n };\n\n readonly #onInput = (event: Event): void => {\n if (!this.revalidateOnInputValue) return;\n const group = this.#snapshot().groupByControl.get(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 (!group || !this.#isTouched(group.key)) return;\n this.#applyGroup(group);\n };\n\n readonly #onChange = (event: Event): void => {\n if (!this.validateOnChangeValue) return;\n const group = this.#snapshot().groupByControl.get(this.#controlFrom(event.target));\n if (!group) 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.#markTouched(group.key);\n this.#applyGroup(group);\n };\n\n /** Suppresses native bubbles and binds the submit / blur / input listeners. */\n override connect(): void {\n if (setDefaultAttribute(this.element, \"novalidate\", \"\")) {\n this.element.setAttribute(FormValidationController.#NOVALIDATE_MARKER, \"\");\n }\n // Capture submit before form-bound actions. The other bubbling events live on\n // the same document so controls associated through `form=\"id\"` participate too;\n // #controlFrom rejects every control owned by a different form.\n this.#listenerDocument = this.element.ownerDocument;\n this.#listenerDocument.addEventListener(\"submit\", this.#onSubmit, true);\n this.#listenerDocument.addEventListener(\"focusout\", this.#onFocusOut);\n this.#listenerDocument.addEventListener(\"input\", this.#onInput);\n this.#listenerDocument.addEventListener(\"change\", this.#onChange);\n }\n\n /** Tears down listeners and restores `novalidate` if we added it. */\n override disconnect(): void {\n this.#listenerDocument?.removeEventListener(\"submit\", this.#onSubmit, true);\n this.#listenerDocument?.removeEventListener(\"focusout\", this.#onFocusOut);\n this.#listenerDocument?.removeEventListener(\"input\", this.#onInput);\n this.#listenerDocument?.removeEventListener(\"change\", this.#onChange);\n this.#listenerDocument = null;\n for (const [control, message] of this.#ownedCustomErrors) {\n if (control.validationMessage === message) control.setCustomValidity(\"\");\n }\n this.#ownedCustomErrors.clear();\n this.#touchedRadioGroups.clear();\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 field/group touched so\n * a later input from any sibling 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 invalid: ValidatableControl[] = [];\n for (const group of this.#snapshot().groups.values()) {\n this.#markTouched(group.key);\n const firstInvalid = this.#applyGroup(group);\n if (firstInvalid) invalid.push(firstInvalid);\n }\n return invalid;\n }\n\n /** Builds the current field table in one pass over form controls and DOM depth. */\n #snapshot(): ValidationSnapshot {\n const fields = this.#fieldOutlets();\n const groups = new Map<FieldGroupKey, FieldGroup>();\n const groupByControl = new Map<ValidatableControl | null, FieldGroup>();\n\n for (const control of this.#controls) {\n const field = this.#fieldFor(control, fields);\n const key = this.#keyFor(control, field);\n let group = groups.get(key);\n if (!group) {\n group = { key, field, controls: [] };\n groups.set(key, group);\n }\n group.controls.push(control);\n groupByControl.set(control, group);\n }\n\n return { groups, groupByControl };\n }\n\n /** Records that a whole field/group, rather than one sibling control, was visited. */\n #markTouched(key: FieldGroupKey): void {\n if (typeof key === \"string\") {\n this.#touchedRadioGroups.add(key);\n } else {\n this.#touchedGroups.add(key);\n }\n }\n\n #isTouched(key: FieldGroupKey): boolean {\n return typeof key === \"string\"\n ? this.#touchedRadioGroups.has(key)\n : this.#touchedGroups.has(key);\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 // Apply declarative custom rules (e.g. disallow=\"whitespace\") before reading\n // validity so they participate in checkValidity() like a native constraint.\n for (const control of group.controls) this.#syncCustomValidity(control);\n const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;\n if (group.field) {\n if (firstInvalid) {\n const message = this.#messageFor(firstInvalid);\n const alreadyReported =\n group.field.element.hasAttribute(FORM_FIELD_INVALID_ATTR) &&\n this.#reportedErrors.get(group.field) === message;\n if (!alreadyReported) group.field.setError(message, { focus: false });\n this.#reportedErrors.set(group.field, message);\n } else if (\n group.field.element.hasAttribute(FORM_FIELD_INVALID_ATTR) ||\n this.#reportedErrors.has(group.field)\n ) {\n group.field.clearError();\n this.#reportedErrors.delete(group.field);\n }\n }\n return firstInvalid;\n }\n\n /**\n * Resolves the message to show for an invalid control: a per-constraint\n * override (`data-stimeo--form-validation-message-<constraint>`) for the first failing\n * `ValidityState` flag, then a generic `data-stimeo--form-validation-message`\n * override, then the browser's native `validationMessage`. Authoring an override\n * gives controlled, localizable, theme-able wording with **no consumer JS** —\n * and sidesteps headless browsers that return an empty native message.\n */\n #messageFor(control: ValidatableControl): string {\n // `setCustomValidity()` owns its wording. In particular, a consumer's custom\n // error must not be replaced by a simultaneous native-constraint override.\n if (control.validity.customError) return control.validationMessage;\n for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {\n if (control.validity[flag]) {\n return (\n this.#authoredMessage(control, `${MESSAGE_ATTR_PREFIX}${key}`) ??\n this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ??\n control.validationMessage\n );\n }\n }\n return control.validationMessage || this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) || \"\";\n }\n\n /**\n * Applies (or clears) a declarative custom constraint via `setCustomValidity`,\n * for controls that opt in with `data-stimeo--form-validation-disallow`. The supported\n * rule is `\"whitespace\"` — a value that is non-empty but blank\n * after trimming (which slips past `required` / `minlength`); its message follows\n * the whitespace-specific → generic → default chain.\n *\n * Don't-clobber-authored-state: an unknown/absent rule is never written, an\n * existing consumer custom error wins, and a controller error is only cleared\n * while the live message still equals the recorded controller write.\n */\n #syncCustomValidity(control: ValidatableControl): void {\n const recorded = this.#ownedCustomErrors.get(control);\n const ownsCurrent = recorded !== undefined && control.validationMessage === recorded;\n\n const violates =\n control.getAttribute(DISALLOW_ATTR) === \"whitespace\" &&\n control.value.length > 0 &&\n control.value.trim() === \"\";\n if (!violates) {\n if (ownsCurrent) control.setCustomValidity(\"\");\n this.#ownedCustomErrors.delete(control);\n return;\n }\n\n // One custom-validity slot exists per control. Preserve a consumer's live\n // entry; once they clear it, the next validation applies the declarative rule.\n if (control.validity.customError && !ownsCurrent) return;\n\n const message =\n this.#authoredMessage(control, DISALLOW_WHITESPACE_MESSAGE) ??\n this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ??\n DISALLOW_WHITESPACE_DEFAULT;\n if (!ownsCurrent || recorded !== message) control.setCustomValidity(message);\n this.#ownedCustomErrors.set(control, message);\n }\n\n /** Returns a non-blank authored message, otherwise falls through to a fallback. */\n #authoredMessage(control: ValidatableControl, attribute: string): string | null {\n const message = control.getAttribute(attribute);\n return message && message.trim().length > 0 ? message : null;\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): FieldGroupKey {\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 — the case 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 (canTakeFocus(control)) return control;\n const field = this.#fieldFor(control, this.#fieldOutlets());\n if (!field?.hasControlTarget) return null;\n const root = field.controlTarget;\n if (isTabStop(root)) return root;\n return firstTabStop(root);\n }\n\n /** Pairs each live outlet element with its controller once per operation. */\n #fieldOutlets(): Map<HTMLElement, FormFieldController> {\n const fields = new Map<HTMLElement, FormFieldController>();\n const elements = this.stimeoFormFieldOutletElements;\n const outlets = this.stimeoFormFieldOutlets;\n for (let index = 0; index < elements.length; index++) {\n const element = elements[index];\n const outlet = outlets[index];\n if (element && outlet) fields.set(element, outlet);\n }\n return fields;\n }\n\n /** The nearest configured field ancestor of `control`, if any. */\n #fieldFor(\n control: ValidatableControl,\n fields: ReadonlyMap<HTMLElement, FormFieldController>,\n ): FormFieldController | undefined {\n let ancestor: HTMLElement | null = control;\n while (ancestor) {\n const field = fields.get(ancestor);\n if (field) return field;\n ancestor = ancestor.parentElement;\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.form === this.element\n ? target\n : 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, input[type=hidden],\n // and button-type controls. The `hidden` attribute intentionally does not\n // bar a text mirror from constraint validation.\n element.willValidate\n );\n }\n}\n"]}