actual-css 0.2.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 (214) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +24 -9
  3. package/dist/actual-themes.min.css +1 -0
  4. package/dist/actual.css +1244 -915
  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 +94 -14
  10. package/docs/design-notes/cascade-layer.md +1 -1
  11. package/docs/design-notes/enhancement-contract.md +11 -14
  12. package/docs/design-notes/forced-colors.md +105 -0
  13. package/docs/design-notes/naming.md +2 -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 +37 -1
  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 +4 -4
  103. package/package.json +20 -12
  104. package/src/css/actual.css +12 -1
  105. package/src/css/base.css +3 -5
  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 +126 -99
  109. package/src/css/components/avatar.css +16 -10
  110. package/src/css/components/badge.css +55 -35
  111. package/src/css/components/breadcrumb.css +7 -15
  112. package/src/css/components/busy.css +6 -0
  113. package/src/css/components/button.css +78 -32
  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 -109
  117. package/src/css/components/index.css +1 -0
  118. package/src/css/components/join.css +14 -2
  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/skeleton.css +7 -0
  128. package/src/css/components/spinner.css +6 -7
  129. package/src/css/components/status-bar.css +15 -24
  130. package/src/css/components/surface.css +2 -8
  131. package/src/css/components/tab.css +19 -48
  132. package/src/css/components/table.css +7 -5
  133. package/src/css/components/tooltip.css +14 -7
  134. package/src/css/focus.css +14 -38
  135. package/src/css/forms/choice-card.css +13 -14
  136. package/src/css/forms/choice.css +21 -27
  137. package/src/css/forms/control.css +16 -17
  138. package/src/css/forms/custom-select.css +9 -11
  139. package/src/css/forms/form-actions.css +14 -3
  140. package/src/css/forms/form.css +7 -4
  141. package/src/css/forms/index.css +4 -4
  142. package/src/css/forms/input-icon.css +30 -12
  143. package/src/css/forms/native.css +50 -11
  144. package/src/css/forms/range.css +17 -9
  145. package/src/css/forms/select.css +8 -8
  146. package/src/css/forms/switch.css +14 -12
  147. package/src/css/forms/validation.css +32 -5
  148. package/src/css/grid.css +19 -18
  149. package/src/css/intents.css +2 -2
  150. package/src/css/layout.css +29 -1
  151. package/src/css/optional/aura.css +93 -0
  152. package/src/css/optional/chat.css +105 -0
  153. package/src/css/optional/fab.css +104 -0
  154. package/src/css/optional/floating-field.css +87 -0
  155. package/src/css/optional/index.css +5 -0
  156. package/src/css/optional/layout-extra.css +7 -4
  157. package/src/css/optional/otp.css +115 -0
  158. package/src/css/optional/scroll-snap.css +6 -0
  159. package/src/css/optional/scroller.css +13 -7
  160. package/src/css/optional/typography-fluid.css +5 -0
  161. package/src/css/optional/utilities-extra.css +17 -2
  162. package/src/css/prose.css +18 -8
  163. package/src/css/reset.css +0 -10
  164. package/src/css/theme.css +21 -5
  165. package/src/css/themes/cyberpunk.css +1 -1
  166. package/src/css/themes/dim.css +3 -3
  167. package/src/css/themes/edge.css +8 -10
  168. package/src/css/themes/index.css +3 -3
  169. package/src/css/themes/{dark.css → indigo.css} +5 -4
  170. package/src/css/themes/neon.css +5 -5
  171. package/src/css/themes/square.css +0 -3
  172. package/src/css/tokens.css +74 -20
  173. package/src/css/utilities.css +8 -10
  174. package/src/css/variants.css +37 -65
  175. package/src/js/command.js +1 -3
  176. package/src/js/context-menu.js +67 -35
  177. package/src/js/dialog.js +68 -79
  178. package/src/js/dismiss.js +15 -0
  179. package/src/js/enhance.js +54 -23
  180. package/src/js/enhancement-loader.js +301 -0
  181. package/src/js/events.js +3 -0
  182. package/src/js/filter.js +3 -3
  183. package/src/js/floating.js +101 -131
  184. package/src/js/flyout.js +104 -87
  185. package/src/js/focus-group.js +116 -0
  186. package/src/js/focus.js +6 -1
  187. package/src/js/index.js +4 -2
  188. package/src/js/input.js +1 -1
  189. package/src/js/keys.js +27 -0
  190. package/src/js/mask.js +7 -6
  191. package/src/js/menu.js +87 -34
  192. package/src/js/parse-config.js +47 -0
  193. package/src/js/password.js +9 -1
  194. package/src/js/scrollspy.js +12 -4
  195. package/src/js/selectors.js +0 -7
  196. package/src/js/status.js +3 -1
  197. package/src/js/surface.js +185 -136
  198. package/src/js/tab.js +36 -53
  199. package/src/js/tooltip.js +53 -31
  200. package/src/js/validation.js +34 -8
  201. package/dist/actual.js.map +0 -31
  202. package/docs/README.md +0 -28
  203. package/docs/components.md +0 -860
  204. package/docs/design-notes/previous-work.md +0 -5
  205. package/docs/forms.md +0 -1265
  206. package/docs/javascript.md +0 -496
  207. package/docs/layout.md +0 -480
  208. package/docs/patterns.md +0 -504
  209. package/docs/reserved.md +0 -7
  210. package/docs/tokens.md +0 -455
  211. package/docs/ui.md +0 -929
  212. package/docs/utilities.md +0 -384
  213. package/src/css/dialog-fallback.css +0 -56
  214. package/src/js/dialog-fallback.js +0 -163
package/src/js/tab.js CHANGED
@@ -16,12 +16,17 @@
16
16
  */
17
17
 
18
18
  import { registerEnhancement } from "./enhance.js";
19
- import { firstItem, lastItem, nextItem } from "./keys.js";
19
+ import { itemForKey } from "./keys.js";
20
20
 
21
21
  const TABLIST_SELECTOR = '[data-enhance~="tabs"]';
22
22
 
23
+ // A tablist owns only the tabs directly beneath it, not those of a nested
24
+ // tablist. Filtering by closest owner keeps the outer list from operating on
25
+ // (and re-reacting to events from) tabs that belong to an inner list.
23
26
  function tabsOf(list) {
24
- return [...list.querySelectorAll('[role="tab"]')];
27
+ return [...list.querySelectorAll('[role="tab"]')].filter(
28
+ (tab) => tab.closest(TABLIST_SELECTOR) === list,
29
+ );
25
30
  }
26
31
 
27
32
  function panelsOf(tabs) {
@@ -35,7 +40,7 @@ function panelsOf(tabs) {
35
40
 
36
41
  function panelFor(tab) {
37
42
  const panelId = tab.getAttribute("aria-controls");
38
- return panelId ? document.getElementById(panelId) : null;
43
+ return panelId ? tab.ownerDocument.getElementById(panelId) : null;
39
44
  }
40
45
 
41
46
  function isTabDisabled(tab) {
@@ -89,68 +94,46 @@ function initialize(list) {
89
94
  }
90
95
 
91
96
  function onKeydown(e) {
97
+ const list = e.currentTarget;
92
98
  const tab = e.target.closest('[role="tab"]');
93
- if (!tab) return;
94
- const list = tab.closest(TABLIST_SELECTOR);
95
- if (!list) return;
99
+ if (!tab || tab.closest(TABLIST_SELECTOR) !== list) return;
96
100
 
97
101
  const tabs = activatableTabs(list);
98
102
  if (!tabs.length || !tabs.includes(tab)) return;
99
103
  const isVertical = list.getAttribute("aria-orientation") === "vertical";
100
- let next;
101
104
 
102
- switch (e.key) {
103
- case "ArrowRight":
104
- if (isVertical) break;
105
- e.preventDefault();
106
- next = nextItem(tabs, tab, 1, { wrap: true });
107
- if (next) activateAndFocus(next);
108
- break;
109
- case "ArrowLeft":
110
- if (isVertical) break;
111
- e.preventDefault();
112
- next = nextItem(tabs, tab, -1, { wrap: true });
113
- if (next) activateAndFocus(next);
114
- break;
115
- case "ArrowDown":
116
- if (!isVertical) {
117
- e.preventDefault();
118
- const panel = panelFor(tab);
119
- if (panel) {
120
- makePanelFocusable(panel);
121
- panel.focus();
122
- }
123
- break;
124
- }
125
- e.preventDefault();
126
- next = nextItem(tabs, tab, 1, { wrap: true });
127
- if (next) activateAndFocus(next);
128
- break;
129
- case "ArrowUp":
130
- if (!isVertical) break;
131
- e.preventDefault();
132
- next = nextItem(tabs, tab, -1, { wrap: true });
133
- if (next) activateAndFocus(next);
134
- break;
135
- case "Home":
136
- e.preventDefault();
137
- activateAndFocus(firstItem(tabs));
138
- break;
139
- case "End":
140
- e.preventDefault();
141
- activateAndFocus(lastItem(tabs));
142
- break;
143
- case "Enter":
144
- case " ":
105
+ if (e.key === "ArrowDown" && !isVertical) {
106
+ const panel = panelFor(tab);
107
+ if (panel) {
145
108
  e.preventDefault();
146
- activate(tab);
147
- break;
109
+ makePanelFocusable(panel);
110
+ panel.focus();
111
+ }
112
+ return;
113
+ }
114
+
115
+ if (e.key === "Enter" || e.key === " ") {
116
+ e.preventDefault();
117
+ activate(tab);
118
+ return;
148
119
  }
120
+
121
+ const direction = list.ownerDocument.defaultView?.getComputedStyle(list).direction || "ltr";
122
+ const target = itemForKey(tabs, tab, e.key, {
123
+ orientation: isVertical ? "vertical" : "horizontal",
124
+ wrap: true,
125
+ direction,
126
+ });
127
+ if (!target) return;
128
+
129
+ e.preventDefault();
130
+ activateAndFocus(target);
149
131
  }
150
132
 
151
133
  function onClick(e) {
134
+ const list = e.currentTarget;
152
135
  const tab = e.target.closest('[role="tab"]');
153
- if (!tab) return;
136
+ if (!tab || tab.closest(TABLIST_SELECTOR) !== list) return;
154
137
  if (isTabDisabled(tab)) {
155
138
  e.preventDefault();
156
139
  return;
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) {
@@ -74,8 +74,11 @@ const rules = {
74
74
  return false;
75
75
  }
76
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).
77
80
  number(v) {
78
- return !Number.isNaN(Number(v));
81
+ return /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(v);
79
82
  },
80
83
  digits(v) {
81
84
  return /^\d+$/.test(v);
@@ -106,18 +109,28 @@ function fieldContainer(el) {
106
109
  return el.closest?.(`.${FIELD_CLASS}`) || null;
107
110
  }
108
111
 
109
- // .field.danger is validation-owned state, added/removed here based on
110
- // aria-invalid/data-validation-errors on the field's controls. A .danger
111
- // applied manually by app code for an unrelated reason will be removed the
112
- // 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
+
113
125
  function syncFieldDangerState(el) {
114
126
  const field = fieldContainer(el);
115
127
  if (!field) return;
116
128
 
117
129
  if (field.querySelector('[aria-invalid="true"], [data-validation-errors]')) {
118
- field.classList.add(DANGER_CLASS);
119
- } else {
130
+ markDangerOwned(field);
131
+ } else if (dangerOwned.has(field)) {
120
132
  field.classList.remove(DANGER_CLASS);
133
+ dangerOwned.delete(field);
121
134
  }
122
135
  }
123
136
 
@@ -181,7 +194,10 @@ function errorEl(el) {
181
194
 
182
195
  function markInvalid(el) {
183
196
  el.setAttribute("aria-invalid", "true");
184
- fieldContainer(el)?.classList.add(DANGER_CLASS);
197
+ const field = fieldContainer(el);
198
+ if (field) {
199
+ markDangerOwned(field);
200
+ }
185
201
  }
186
202
 
187
203
  function markValid(el) {
@@ -322,6 +338,10 @@ function connectForm(form) {
322
338
  { signal: controller.signal },
323
339
  );
324
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.
325
345
  const cleanup = () => {
326
346
  controller.abort();
327
347
  releaseManagedNoValidate(form);
@@ -338,6 +358,12 @@ export class FormValidator {
338
358
  }
339
359
 
340
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
+ }
341
367
  rules[name] = callback;
342
368
  }
343
369