actual-css 0.1.0 → 0.3.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 (215) hide show
  1. package/CHANGELOG.md +90 -1
  2. package/README.md +25 -8
  3. package/dist/actual-themes.min.css +1 -0
  4. package/dist/actual.css +1492 -931
  5. package/dist/actual.js +1 -3
  6. package/dist/actual.min.css +1 -1
  7. package/dist/optional.css +1048 -0
  8. package/dist/optional.min.css +1 -0
  9. package/docs/design-notes/browser-support.md +95 -6
  10. package/docs/design-notes/cascade-layer.md +1 -1
  11. package/docs/design-notes/enhancement-contract.md +101 -0
  12. package/docs/design-notes/forced-colors.md +105 -0
  13. package/docs/design-notes/naming.md +3 -1
  14. package/docs/design-notes/platform-alignment.md +492 -0
  15. package/docs/design-notes/references.md +8 -0
  16. package/docs/design-notes/widget-primitives.md +119 -0
  17. package/docs/navigation.json +135 -0
  18. package/docs/pages/components/accordion.md +80 -0
  19. package/docs/pages/components/alert.md +203 -0
  20. package/docs/pages/components/aura.md +35 -0
  21. package/docs/pages/components/avatar.md +95 -0
  22. package/docs/pages/components/badge.md +102 -0
  23. package/docs/pages/components/breadcrumb.md +19 -0
  24. package/docs/pages/components/button.md +205 -0
  25. package/docs/pages/components/card.md +183 -0
  26. package/docs/pages/components/chat.md +50 -0
  27. package/docs/pages/components/dialog.md +367 -0
  28. package/docs/pages/components/drawer.md +113 -0
  29. package/docs/pages/components/fab.md +118 -0
  30. package/docs/pages/components/key.md +33 -0
  31. package/docs/pages/components/meter.md +23 -0
  32. package/docs/pages/components/navbar.md +92 -0
  33. package/docs/pages/components/pagination.md +55 -0
  34. package/docs/pages/components/progress.md +29 -0
  35. package/docs/pages/components/skeleton.md +38 -0
  36. package/docs/pages/components/spinner.md +93 -0
  37. package/docs/pages/components/table.md +102 -0
  38. package/docs/pages/enhancements/flyout.md +391 -0
  39. package/docs/pages/enhancements/overview.md +36 -0
  40. package/docs/pages/enhancements/scrollspy.md +71 -0
  41. package/docs/pages/enhancements/status-bar.md +72 -0
  42. package/docs/pages/enhancements/tabs.md +80 -0
  43. package/docs/pages/enhancements/tooltip.md +130 -0
  44. package/docs/pages/examples/overview.md +41 -0
  45. package/docs/pages/forms/choice-cards.md +102 -0
  46. package/docs/pages/forms/controls.md +32 -0
  47. package/docs/pages/forms/detached-actions.md +59 -0
  48. package/docs/pages/forms/floating-field.md +88 -0
  49. package/docs/pages/forms/form-actions.md +56 -0
  50. package/docs/pages/forms/input-policy.md +91 -0
  51. package/docs/pages/forms/joined-controls.md +37 -0
  52. package/docs/pages/forms/labels.md +42 -0
  53. package/docs/pages/forms/otp.md +50 -0
  54. package/docs/pages/forms/overview.md +188 -0
  55. package/docs/pages/forms/password.md +40 -0
  56. package/docs/pages/forms/range.md +17 -0
  57. package/docs/pages/forms/states-and-sizes.md +321 -0
  58. package/docs/pages/forms/sticky-actions.md +187 -0
  59. package/docs/pages/forms/switch.md +31 -0
  60. package/docs/pages/forms/validation.md +241 -0
  61. package/docs/{mental-model.md → pages/foundations/philosophy.md} +33 -18
  62. package/docs/pages/foundations/theming.md +92 -0
  63. package/docs/pages/foundations/tokens.md +421 -0
  64. package/docs/{typography.md → pages/foundations/typography.md} +27 -9
  65. package/docs/pages/guides/bootstrap.md +438 -0
  66. package/docs/pages/guides/introduction.md +157 -0
  67. package/docs/pages/guides/optional-components.md +33 -0
  68. package/docs/pages/guides/progressive-enhancement.md +686 -0
  69. package/docs/pages/guides/tailwind.md +455 -0
  70. package/docs/pages/layout/app-shell.md +25 -0
  71. package/docs/pages/layout/center.md +29 -0
  72. package/docs/pages/layout/cluster.md +57 -0
  73. package/docs/pages/layout/frame.md +30 -0
  74. package/docs/pages/layout/grid.md +142 -0
  75. package/docs/pages/layout/list.md +41 -0
  76. package/docs/pages/layout/media.md +21 -0
  77. package/docs/pages/layout/overview.md +37 -0
  78. package/docs/pages/layout/scroll-snap.md +54 -0
  79. package/docs/pages/layout/sidebar-layout.md +27 -0
  80. package/docs/pages/layout/stack.md +18 -0
  81. package/docs/pages/layout/switcher.md +36 -0
  82. package/docs/pages/layout/topbar.md +23 -0
  83. package/docs/pages/patterns/actions.md +52 -0
  84. package/docs/pages/patterns/code-block.md +36 -0
  85. package/docs/pages/patterns/exclusive-accordion.md +16 -0
  86. package/docs/pages/patterns/hierarchical-nav.md +26 -0
  87. package/docs/pages/patterns/icon-input.md +25 -0
  88. package/docs/pages/patterns/intent-nesting.md +3 -0
  89. package/docs/pages/patterns/navigation-list.md +235 -0
  90. package/docs/pages/patterns/overview.md +58 -0
  91. package/docs/pages/patterns/structured-lists.md +47 -0
  92. package/docs/pages/utilities/accessibility.md +34 -0
  93. package/docs/pages/utilities/flex.md +105 -0
  94. package/docs/pages/utilities/links.md +18 -0
  95. package/docs/pages/utilities/list.md +31 -0
  96. package/docs/pages/utilities/overflow.md +43 -0
  97. package/docs/pages/utilities/overview.md +18 -0
  98. package/docs/pages/utilities/shape.md +25 -0
  99. package/docs/pages/utilities/sizing-wrapping.md +115 -0
  100. package/docs/pages/utilities/spacing.md +79 -0
  101. package/docs/pages/utilities/text.md +43 -0
  102. package/llms.txt +10 -4
  103. package/package.json +30 -16
  104. package/src/css/actual.css +12 -1
  105. package/src/css/base.css +3 -9
  106. package/src/css/components/accordion.css +18 -30
  107. package/src/css/components/actions.css +4 -1
  108. package/src/css/components/alert.css +162 -49
  109. package/src/css/components/avatar.css +16 -10
  110. package/src/css/components/badge.css +82 -39
  111. package/src/css/components/breadcrumb.css +7 -15
  112. package/src/css/components/busy.css +7 -0
  113. package/src/css/components/button.css +82 -38
  114. package/src/css/components/card.css +17 -37
  115. package/src/css/components/drawer.css +30 -13
  116. package/src/css/components/flyout.css +39 -110
  117. package/src/css/components/index.css +1 -0
  118. package/src/css/components/join.css +14 -3
  119. package/src/css/components/key.css +2 -0
  120. package/src/css/components/menu.css +125 -0
  121. package/src/css/components/meter.css +0 -23
  122. package/src/css/components/modal.css +162 -7
  123. package/src/css/components/navbar.css +17 -18
  124. package/src/css/components/overline.css +0 -7
  125. package/src/css/components/pagination.css +1 -1
  126. package/src/css/components/progress.css +8 -26
  127. package/src/css/components/scrollspy.css +2 -2
  128. package/src/css/components/skeleton.css +7 -0
  129. package/src/css/components/spinner.css +10 -11
  130. package/src/css/components/status-bar.css +18 -28
  131. package/src/css/components/surface.css +2 -8
  132. package/src/css/components/tab.css +20 -48
  133. package/src/css/components/table.css +7 -5
  134. package/src/css/components/tooltip.css +14 -7
  135. package/src/css/focus.css +16 -3
  136. package/src/css/forms/choice-card.css +13 -15
  137. package/src/css/forms/choice.css +21 -28
  138. package/src/css/forms/control.css +16 -26
  139. package/src/css/forms/custom-select.css +9 -11
  140. package/src/css/forms/form-actions.css +14 -3
  141. package/src/css/forms/form.css +7 -4
  142. package/src/css/forms/index.css +5 -4
  143. package/src/css/forms/input-icon.css +30 -12
  144. package/src/css/forms/native.css +48 -33
  145. package/src/css/forms/range.css +103 -0
  146. package/src/css/forms/select.css +8 -8
  147. package/src/css/forms/switch.css +14 -13
  148. package/src/css/forms/validation.css +32 -5
  149. package/src/css/grid.css +19 -18
  150. package/src/css/intents.css +2 -2
  151. package/src/css/layout.css +29 -1
  152. package/src/css/optional/aura.css +93 -0
  153. package/src/css/optional/chat.css +105 -0
  154. package/src/css/optional/fab.css +104 -0
  155. package/src/css/optional/floating-field.css +87 -0
  156. package/src/css/optional/index.css +5 -0
  157. package/src/css/optional/layout-extra.css +7 -4
  158. package/src/css/optional/otp.css +115 -0
  159. package/src/css/optional/scroll-snap.css +6 -0
  160. package/src/css/optional/scroller.css +19 -7
  161. package/src/css/optional/typography-fluid.css +5 -0
  162. package/src/css/optional/utilities-extra.css +17 -2
  163. package/src/css/prose.css +18 -8
  164. package/src/css/reset.css +0 -10
  165. package/src/css/theme.css +29 -13
  166. package/src/css/themes/cyberpunk.css +1 -1
  167. package/src/css/themes/dim.css +3 -3
  168. package/src/css/themes/edge.css +11 -37
  169. package/src/css/themes/index.css +3 -3
  170. package/src/css/themes/{dark.css → indigo.css} +5 -4
  171. package/src/css/themes/neon.css +5 -5
  172. package/src/css/themes/square.css +0 -3
  173. package/src/css/tokens.css +81 -21
  174. package/src/css/utilities.css +8 -10
  175. package/src/css/variants.css +37 -65
  176. package/src/js/command.js +1 -3
  177. package/src/js/context-menu.js +72 -53
  178. package/src/js/dialog.js +68 -79
  179. package/src/js/dismiss.js +15 -0
  180. package/src/js/enhance.js +76 -19
  181. package/src/js/enhancement-loader.js +301 -0
  182. package/src/js/events.js +3 -0
  183. package/src/js/filter.js +3 -3
  184. package/src/js/floating.js +101 -131
  185. package/src/js/flyout.js +106 -96
  186. package/src/js/focus-group.js +116 -0
  187. package/src/js/focus.js +6 -1
  188. package/src/js/index.js +4 -2
  189. package/src/js/input.js +1 -1
  190. package/src/js/keys.js +27 -0
  191. package/src/js/mask.js +7 -6
  192. package/src/js/menu.js +89 -35
  193. package/src/js/parse-config.js +47 -0
  194. package/src/js/password.js +9 -1
  195. package/src/js/scrollspy.js +133 -62
  196. package/src/js/selectors.js +14 -31
  197. package/src/js/status.js +16 -14
  198. package/src/js/surface.js +219 -125
  199. package/src/js/tab.js +46 -66
  200. package/src/js/tooltip.js +53 -31
  201. package/src/js/validation.js +45 -51
  202. package/dist/actual.js.map +0 -31
  203. package/docs/README.md +0 -26
  204. package/docs/components.md +0 -860
  205. package/docs/design-notes/previous-work.md +0 -5
  206. package/docs/forms.md +0 -1265
  207. package/docs/javascript.md +0 -426
  208. package/docs/layout.md +0 -480
  209. package/docs/patterns.md +0 -415
  210. package/docs/reserved.md +0 -7
  211. package/docs/tokens.md +0 -455
  212. package/docs/ui.md +0 -937
  213. package/docs/utilities.md +0 -384
  214. package/src/css/dialog-fallback.css +0 -56
  215. package/src/js/dialog-fallback.js +0 -163
package/src/js/tooltip.js CHANGED
@@ -28,8 +28,7 @@
28
28
  */
29
29
 
30
30
  import enhance from "./enhance.js";
31
- import { EVENTS } from "./events.js";
32
- import { reposition, track } from "./floating.js";
31
+ import { autoUpdate, reposition } from "./floating.js";
33
32
  import { CLASSES } from "./selectors.js";
34
33
 
35
34
  const SHOW_DELAY_MS = 150;
@@ -40,12 +39,13 @@ const HIDE_DELAY_MS = 100;
40
39
  * @property {Set<Element>} refs Triggers that currently reference this tip.
41
40
  * @property {Element | null} activeRef Trigger that last showed this tip.
42
41
  * @property {AbortController} controller Tip-level event listener cleanup.
43
- * @property {() => void} untrack Floating-position cleanup.
42
+ * @property {() => void} stopTracking Floating-position cleanup.
44
43
  * @property {boolean} generated True when the tip was created from data-tooltip text.
45
44
  * @property {{ parent: Node, next: ChildNode | null } | null} mount Original DOM position for explicit tips moved into a dialog/body root.
46
45
  * @property {ReturnType<typeof setTimeout> | null} timer Pending delayed show.
47
46
  * @property {ReturnType<typeof setTimeout> | null} hideTimer Pending delayed hide.
48
- * @property {boolean} overRef Pointer is over the active trigger.
47
+ * @property {boolean} hovered Pointer is over the active trigger.
48
+ * @property {boolean} focused The active trigger has keyboard focus.
49
49
  * @property {boolean} overTip Pointer is over the tooltip.
50
50
  */
51
51
 
@@ -132,7 +132,7 @@ function nextTooltipId(doc) {
132
132
  }
133
133
 
134
134
  function repositionTip(ref, tip) {
135
- reposition(ref, tip, {
135
+ return reposition(ref, tip, {
136
136
  placement: placementFor(ref),
137
137
  distance: 6,
138
138
  flip: true,
@@ -165,11 +165,11 @@ function scheduleHide(tip) {
165
165
  const state = tipStates.get(tip);
166
166
  if (!state) return;
167
167
  if (isClickTrigger(state.activeRef) || isAlwaysVisible(state.activeRef)) return;
168
- if (state.overRef || state.overTip) return;
168
+ if (state.hovered || state.focused || state.overTip) return;
169
169
  if (state.hideTimer) clearTimeout(state.hideTimer);
170
170
  state.hideTimer = setTimeout(() => {
171
171
  state.hideTimer = null;
172
- if (!state.overRef && !state.overTip) hideTip(tip);
172
+ if (!state.hovered && !state.focused && !state.overTip) hideTip(tip);
173
173
  }, HIDE_DELAY_MS);
174
174
  }
175
175
 
@@ -185,25 +185,21 @@ function wireTip(tip, options = {}) {
185
185
  refs: new Set(),
186
186
  activeRef: null,
187
187
  controller,
188
- untrack: track(tip),
188
+ stopTracking: autoUpdate(tip, () => {
189
+ if (isAlwaysVisible(state.activeRef)) tip.hidden = false;
190
+ if (!tip.hidden && state.activeRef) {
191
+ if (!repositionTip(state.activeRef, tip)) hideTip(tip, true);
192
+ }
193
+ }),
189
194
  generated: options.generated === true,
190
195
  mount: options.mount || null,
191
196
  timer: null,
192
197
  hideTimer: null,
193
- overRef: false,
198
+ hovered: false,
199
+ focused: false,
194
200
  overTip: false,
195
201
  };
196
202
 
197
- const onHide = () => hideTip(tip);
198
- const onOutOfView = () => hideTip(tip, true);
199
- const onReposition = () => {
200
- if (isAlwaysVisible(state.activeRef)) tip.hidden = false;
201
- if (!tip.hidden && state.activeRef) repositionTip(state.activeRef, tip);
202
- };
203
-
204
- tip.addEventListener(EVENTS.reposition, onReposition, { signal: controller.signal });
205
- tip.addEventListener(EVENTS.hide, onHide, { signal: controller.signal });
206
- tip.addEventListener(EVENTS.outOfView, onOutOfView, { signal: controller.signal });
207
203
  tip.addEventListener(
208
204
  "mouseenter",
209
205
  () => {
@@ -221,6 +217,17 @@ function wireTip(tip, options = {}) {
221
217
  { signal: controller.signal },
222
218
  );
223
219
 
220
+ tip.ownerDocument.addEventListener(
221
+ "keydown",
222
+ (event) => {
223
+ if (event.key !== "Escape" || event.ctrlKey || event.altKey || event.shiftKey) return;
224
+ if (tip.hidden) return;
225
+ event.preventDefault();
226
+ hideTip(tip);
227
+ },
228
+ { signal: controller.signal },
229
+ );
230
+
224
231
  tipStates.set(tip, state);
225
232
  return state;
226
233
  }
@@ -256,10 +263,11 @@ function ensureTip(trigger) {
256
263
  if (!tip) {
257
264
  tip = explicitTipFor(trigger);
258
265
  if (!tip) return null;
259
- tip.hidden = true;
260
- tip.style.position = "fixed";
261
-
266
+ // Wire and mount the shared tip only on first retain. A later trigger
267
+ // must not re-hide a tooltip that is currently visible for another.
262
268
  if (!tipStates.has(tip)) {
269
+ tip.hidden = true;
270
+ tip.style.position = "fixed";
263
271
  mount = mountTip(tip, trigger);
264
272
  }
265
273
  }
@@ -268,12 +276,22 @@ function ensureTip(trigger) {
268
276
  state.refs.add(trigger);
269
277
 
270
278
  const triggerController = new AbortController();
271
- const onLeave = () => {
272
- state.overRef = false;
273
- scheduleHide(tip);
274
- };
275
- trigger.addEventListener("mouseleave", onLeave, { signal: triggerController.signal });
276
- trigger.addEventListener("blur", onLeave, { signal: triggerController.signal });
279
+ trigger.addEventListener(
280
+ "mouseleave",
281
+ () => {
282
+ state.hovered = false;
283
+ scheduleHide(tip);
284
+ },
285
+ { signal: triggerController.signal },
286
+ );
287
+ trigger.addEventListener(
288
+ "blur",
289
+ () => {
290
+ state.focused = false;
291
+ scheduleHide(tip);
292
+ },
293
+ { signal: triggerController.signal },
294
+ );
277
295
 
278
296
  const cleanup = () => {
279
297
  triggerController.abort();
@@ -290,7 +308,7 @@ function ensureTip(trigger) {
290
308
 
291
309
  hideTip(tip, true);
292
310
  state.controller.abort();
293
- state.untrack();
311
+ state.stopTracking();
294
312
  tipStates.delete(tip);
295
313
  if (state.generated) tip.remove();
296
314
  else restoreTip(tip, state);
@@ -310,7 +328,6 @@ function show(tip, ref, immediate = false) {
310
328
  if (!state) return;
311
329
 
312
330
  state.activeRef = ref;
313
- state.overRef = true;
314
331
  clearHide(tip);
315
332
  if (state.timer) clearTimeout(state.timer);
316
333
  const reveal = () => {
@@ -339,7 +356,12 @@ function handleTriggerIntent(e) {
339
356
  return;
340
357
 
341
358
  const tip = ensureTip(trigger);
342
- if (tip) show(tip, trigger);
359
+ if (!tip) return;
360
+
361
+ const state = tipStates.get(tip);
362
+ if (e.type === "focusin") state.focused = true;
363
+ else state.hovered = true;
364
+ show(tip, trigger);
343
365
  }
344
366
 
345
367
  function handleTriggerClick(e) {
@@ -8,27 +8,22 @@
8
8
  * framework: server / AJAX validation lives in app code and feeds results
9
9
  * back through FormValidator.setErrors().
10
10
  *
11
- * Opt in with the .needs-validation class. Importing the module registers
12
- * the behavior; there is no init call.
11
+ * Opt in with data-enhance="validation". The .needs-validation class
12
+ * remains valid for presentation-only (CSS :user-invalid feedback without
13
+ * the JS behavior). Importing the module registers the behavior; there is
14
+ * no init call.
13
15
  *
14
- * The .needs-validation class is a static, init-time contract: this module
15
- * does not observe attribute mutations, so toggling the class after connect
16
- * only takes effect on the next focusout/input event, not immediately. A
17
- * submit that races that window can still bypass native validation (the
18
- * managed novalidate attribute is still present). Treat the class as fixed
19
- * for a form's lifetime rather than something toggled at runtime. Removing
20
- * the form from the DOM, however, is fully supported: the enhance() sweep
21
- * runs the cleanup below, which aborts all listeners, removes the managed
22
- * novalidate attribute, and releases the form's internal state.
16
+ * The marker is a static, init-time contract. Removing the form from the
17
+ * DOM tears down everything, including the managed novalidate attribute.
23
18
  */
24
19
 
25
- import enhance from "./enhance.js";
20
+ import enhance, { enhancementSelector } from "./enhance.js";
26
21
  import { EVENTS } from "./events.js";
27
22
  import { CLASSES } from "./selectors.js";
28
23
 
29
24
  const NOVALIDATE = "novalidate";
30
25
  const WAS_VALIDATED_CLASS = CLASSES.wasValidated;
31
- const NEEDS_VALIDATION_CLASS = CLASSES.needsValidation;
26
+ const VALIDATION_SELECTOR = enhancementSelector("validation");
32
27
  const FIELD_CLASS = CLASSES.field;
33
28
  const DANGER_CLASS = CLASSES.danger;
34
29
  const MANAGED_NOVALIDATE_ATTR = "validationManagedNovalidate";
@@ -79,8 +74,11 @@ const rules = {
79
74
  return false;
80
75
  }
81
76
  },
77
+ // A user-entered decimal: optional sign, digits with optional fraction,
78
+ // optional exponent. Rejects JS-isms that Number() would accept (0x10,
79
+ // Infinity, scientific shorthand, whitespace).
82
80
  number(v) {
83
- return !Number.isNaN(Number(v));
81
+ return /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(v);
84
82
  },
85
83
  digits(v) {
86
84
  return /^\d+$/.test(v);
@@ -111,18 +109,28 @@ function fieldContainer(el) {
111
109
  return el.closest?.(`.${FIELD_CLASS}`) || null;
112
110
  }
113
111
 
114
- // .field.danger is validation-owned state, added/removed here based on
115
- // aria-invalid/data-validation-errors on the field's controls. A .danger
116
- // applied manually by app code for an unrelated reason will be removed the
117
- // next time this runs.
112
+ // .field.danger added by validation is tracked in a WeakSet so that only
113
+ // validation-owned state is removed. A .danger applied manually by app code
114
+ // for an unrelated reason is left alone: validation claims ownership only
115
+ // when it introduces the class itself.
116
+ const dangerOwned = new WeakSet();
117
+
118
+ function markDangerOwned(field) {
119
+ if (!field.classList.contains(DANGER_CLASS)) {
120
+ dangerOwned.add(field);
121
+ }
122
+ field.classList.add(DANGER_CLASS);
123
+ }
124
+
118
125
  function syncFieldDangerState(el) {
119
126
  const field = fieldContainer(el);
120
127
  if (!field) return;
121
128
 
122
129
  if (field.querySelector('[aria-invalid="true"], [data-validation-errors]')) {
123
- field.classList.add(DANGER_CLASS);
124
- } else {
130
+ markDangerOwned(field);
131
+ } else if (dangerOwned.has(field)) {
125
132
  field.classList.remove(DANGER_CLASS);
133
+ dangerOwned.delete(field);
126
134
  }
127
135
  }
128
136
 
@@ -177,7 +185,7 @@ function errorEl(el) {
177
185
  if (!ids) return null;
178
186
  for (const id of ids.split(/\s+/)) {
179
187
  const node = el.ownerDocument.getElementById(id);
180
- if (node?.classList.contains(CLASSES.fieldError)) {
188
+ if (node?.matches(`.${CLASSES.fieldError}, [data-field-error]`)) {
181
189
  return node;
182
190
  }
183
191
  }
@@ -186,7 +194,10 @@ function errorEl(el) {
186
194
 
187
195
  function markInvalid(el) {
188
196
  el.setAttribute("aria-invalid", "true");
189
- fieldContainer(el)?.classList.add(DANGER_CLASS);
197
+ const field = fieldContainer(el);
198
+ if (field) {
199
+ markDangerOwned(field);
200
+ }
190
201
  }
191
202
 
192
203
  function markValid(el) {
@@ -205,14 +216,6 @@ function hydrateServerErrors(form) {
205
216
  }
206
217
  }
207
218
 
208
- function ensureManagedNoValidate(form) {
209
- if (!form.classList.contains(NEEDS_VALIDATION_CLASS)) return;
210
- if (form.hasAttribute(NOVALIDATE)) return;
211
-
212
- form.setAttribute(NOVALIDATE, "");
213
- form.dataset[MANAGED_NOVALIDATE_ATTR] = "";
214
- }
215
-
216
219
  function releaseManagedNoValidate(form) {
217
220
  if (form.dataset[MANAGED_NOVALIDATE_ATTR] === undefined) return;
218
221
  form.removeAttribute(NOVALIDATE);
@@ -220,8 +223,6 @@ function releaseManagedNoValidate(form) {
220
223
  }
221
224
 
222
225
  function validateField(el, trigger) {
223
- const form = el.form;
224
- if (!form?.classList.contains(NEEDS_VALIDATION_CLASS)) return;
225
226
  if (ignoreField(el)) return;
226
227
 
227
228
  if (trigger === "input" && el.dataset.validationErrors === "server") {
@@ -261,11 +262,6 @@ function connectForm(form) {
261
262
  (event) => {
262
263
  const el = event.target;
263
264
  if (el instanceof Element && el.form === form) {
264
- if (!form.classList.contains(NEEDS_VALIDATION_CLASS)) {
265
- releaseManagedNoValidate(form);
266
- return;
267
- }
268
- ensureManagedNoValidate(form);
269
265
  validateField(el, "blur");
270
266
  }
271
267
  },
@@ -277,11 +273,6 @@ function connectForm(form) {
277
273
  (event) => {
278
274
  const el = event.target;
279
275
  if (el instanceof Element && el.form === form) {
280
- if (!form.classList.contains(NEEDS_VALIDATION_CLASS)) {
281
- releaseManagedNoValidate(form);
282
- return;
283
- }
284
- ensureManagedNoValidate(form);
285
276
  validateField(el, "input");
286
277
  }
287
278
  },
@@ -306,13 +297,6 @@ function connectForm(form) {
306
297
  (event) => {
307
298
  if (event.submitter?.formNoValidate) return;
308
299
 
309
- if (!form.classList.contains(NEEDS_VALIDATION_CLASS)) {
310
- releaseManagedNoValidate(form);
311
- return;
312
- }
313
-
314
- ensureManagedNoValidate(form);
315
-
316
300
  let firstInvalid = null;
317
301
 
318
302
  for (const el of form.elements) {
@@ -354,6 +338,10 @@ function connectForm(form) {
354
338
  { signal: controller.signal },
355
339
  );
356
340
 
341
+ // Disconnect contract: release resources only. Validation-owned classes,
342
+ // aria-invalid, custom validity, and rendered errors are intentionally left
343
+ // in place, so a move/remove/reinsert of the form does not erase business
344
+ // state. A full reset goes through the reset event, not teardown.
357
345
  const cleanup = () => {
358
346
  controller.abort();
359
347
  releaseManagedNoValidate(form);
@@ -370,10 +358,16 @@ export class FormValidator {
370
358
  }
371
359
 
372
360
  static registerRule(name, callback) {
361
+ if (typeof name !== "string" || name.trim() === "") {
362
+ throw new TypeError("registerRule() requires a non-empty rule name.");
363
+ }
364
+ if (typeof callback !== "function") {
365
+ throw new TypeError(`registerRule("${name}") requires a function callback.`);
366
+ }
373
367
  rules[name] = callback;
374
368
  }
375
369
 
376
- static init(selector = `.${NEEDS_VALIDATION_CLASS}`) {
370
+ static init(selector = VALIDATION_SELECTOR) {
377
371
  if (typeof document === "undefined") return;
378
372
  for (const form of document.querySelectorAll(selector)) {
379
373
  if (isFormElement(form)) connectForm(form);
@@ -412,7 +406,7 @@ export class FormValidator {
412
406
 
413
407
  if (typeof document !== "undefined") {
414
408
  enhance({
415
- [`form.${NEEDS_VALIDATION_CLASS}`]: (form) => connectForm(form),
409
+ [`form${VALIDATION_SELECTOR}`]: (form) => connectForm(form),
416
410
  });
417
411
  }
418
412