actual-css 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +24 -9
  3. package/dist/actual-themes.min.css +1 -0
  4. package/dist/actual.css +1532 -1114
  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 +7 -6
  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 +210 -0
  25. package/docs/pages/components/card.md +195 -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 +43 -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 +99 -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 +59 -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 +28 -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 +55 -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 +32 -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 +21 -12
  104. package/src/css/actual.css +16 -2
  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 +139 -102
  109. package/src/css/components/avatar.css +16 -10
  110. package/src/css/components/badge.css +69 -42
  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 +84 -34
  114. package/src/css/components/card.css +26 -38
  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 +30 -23
  124. package/src/css/components/overline.css +8 -9
  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 +18 -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 +32 -18
  149. package/src/css/intents.css +2 -2
  150. package/src/css/layout.css +45 -2
  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 +8 -4
  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 +51 -66
  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 +187 -136
  198. package/src/js/tab.js +36 -53
  199. package/src/js/tooltip.js +70 -35
  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
@@ -14,9 +14,9 @@
14
14
  * treated as a tooltip trigger.
15
15
  *
16
16
  * Sharing: an explicit tooltip may be referenced by several triggers. The
17
- * tooltip element is wired once (floating listeners + tracking);
18
- * each trigger only registers itself and the wiring is torn down
19
- * when the last trigger leaves the DOM.
17
+ * tooltip element is wired once; each trigger only registers
18
+ * itself. Positional tracking runs only while the tip is visible,
19
+ * and all wiring is torn down when the last trigger leaves the DOM.
20
20
  *
21
21
  * Lazy: tooltip elements are created (shorthand) or wired (explicit) on
22
22
  * first mouseover / focusin. No page-load scan, no DOM overhead
@@ -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,19 +39,20 @@ 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) | null} stopTracking Floating-position cleanup while visible.
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
 
52
52
  let uid = 0;
53
53
  const triggerStates = new WeakMap(); // trigger -> { tip, cleanup }
54
54
  /** @type {WeakMap<Element, TooltipState>} */
55
- const tipStates = new WeakMap(); // tip -> { refs, activeRef, controller, untrack, generated, mount, timer }
55
+ const tipStates = new WeakMap(); // tip -> { refs, activeRef, controller, stopTracking, generated, mount, timer }
56
56
 
57
57
  function mountTip(tip, trigger) {
58
58
  const root = trigger.closest("dialog") || trigger.ownerDocument.body;
@@ -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,
@@ -140,6 +140,22 @@ function repositionTip(ref, tip) {
140
140
  });
141
141
  }
142
142
 
143
+ function startTracking(tip, state) {
144
+ if (state.stopTracking) return;
145
+
146
+ state.stopTracking = autoUpdate(tip, () => {
147
+ if (isAlwaysVisible(state.activeRef)) tip.hidden = false;
148
+ if (state.activeRef && !tip.hidden && !repositionTip(state.activeRef, tip)) {
149
+ hideTip(tip, true);
150
+ }
151
+ });
152
+ }
153
+
154
+ function stopTracking(state) {
155
+ state?.stopTracking?.();
156
+ if (state) state.stopTracking = null;
157
+ }
158
+
143
159
  function hideTip(tip, force = false) {
144
160
  const state = tipStates.get(tip);
145
161
  if (!force && isAlwaysVisible(state?.activeRef)) return;
@@ -152,6 +168,7 @@ function hideTip(tip, force = false) {
152
168
  state.hideTimer = null;
153
169
  }
154
170
  tip.hidden = true;
171
+ stopTracking(state);
155
172
  }
156
173
 
157
174
  function clearHide(tip) {
@@ -165,11 +182,11 @@ function scheduleHide(tip) {
165
182
  const state = tipStates.get(tip);
166
183
  if (!state) return;
167
184
  if (isClickTrigger(state.activeRef) || isAlwaysVisible(state.activeRef)) return;
168
- if (state.overRef || state.overTip) return;
185
+ if (state.hovered || state.focused || state.overTip) return;
169
186
  if (state.hideTimer) clearTimeout(state.hideTimer);
170
187
  state.hideTimer = setTimeout(() => {
171
188
  state.hideTimer = null;
172
- if (!state.overRef && !state.overTip) hideTip(tip);
189
+ if (!state.hovered && !state.focused && !state.overTip) hideTip(tip);
173
190
  }, HIDE_DELAY_MS);
174
191
  }
175
192
 
@@ -185,25 +202,16 @@ function wireTip(tip, options = {}) {
185
202
  refs: new Set(),
186
203
  activeRef: null,
187
204
  controller,
188
- untrack: track(tip),
205
+ stopTracking: null,
189
206
  generated: options.generated === true,
190
207
  mount: options.mount || null,
191
208
  timer: null,
192
209
  hideTimer: null,
193
- overRef: false,
210
+ hovered: false,
211
+ focused: false,
194
212
  overTip: false,
195
213
  };
196
214
 
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
215
  tip.addEventListener(
208
216
  "mouseenter",
209
217
  () => {
@@ -221,6 +229,17 @@ function wireTip(tip, options = {}) {
221
229
  { signal: controller.signal },
222
230
  );
223
231
 
232
+ tip.ownerDocument.addEventListener(
233
+ "keydown",
234
+ (event) => {
235
+ if (event.key !== "Escape" || event.ctrlKey || event.altKey || event.shiftKey) return;
236
+ if (tip.hidden) return;
237
+ event.preventDefault();
238
+ hideTip(tip);
239
+ },
240
+ { signal: controller.signal },
241
+ );
242
+
224
243
  tipStates.set(tip, state);
225
244
  return state;
226
245
  }
@@ -256,10 +275,11 @@ function ensureTip(trigger) {
256
275
  if (!tip) {
257
276
  tip = explicitTipFor(trigger);
258
277
  if (!tip) return null;
259
- tip.hidden = true;
260
- tip.style.position = "fixed";
261
-
278
+ // Wire and mount the shared tip only on first retain. A later trigger
279
+ // must not re-hide a tooltip that is currently visible for another.
262
280
  if (!tipStates.has(tip)) {
281
+ tip.hidden = true;
282
+ tip.style.position = "fixed";
263
283
  mount = mountTip(tip, trigger);
264
284
  }
265
285
  }
@@ -268,12 +288,22 @@ function ensureTip(trigger) {
268
288
  state.refs.add(trigger);
269
289
 
270
290
  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 });
291
+ trigger.addEventListener(
292
+ "mouseleave",
293
+ () => {
294
+ state.hovered = false;
295
+ scheduleHide(tip);
296
+ },
297
+ { signal: triggerController.signal },
298
+ );
299
+ trigger.addEventListener(
300
+ "blur",
301
+ () => {
302
+ state.focused = false;
303
+ scheduleHide(tip);
304
+ },
305
+ { signal: triggerController.signal },
306
+ );
277
307
 
278
308
  const cleanup = () => {
279
309
  triggerController.abort();
@@ -290,7 +320,7 @@ function ensureTip(trigger) {
290
320
 
291
321
  hideTip(tip, true);
292
322
  state.controller.abort();
293
- state.untrack();
323
+ stopTracking(state);
294
324
  tipStates.delete(tip);
295
325
  if (state.generated) tip.remove();
296
326
  else restoreTip(tip, state);
@@ -310,13 +340,13 @@ function show(tip, ref, immediate = false) {
310
340
  if (!state) return;
311
341
 
312
342
  state.activeRef = ref;
313
- state.overRef = true;
314
343
  clearHide(tip);
315
344
  if (state.timer) clearTimeout(state.timer);
316
345
  const reveal = () => {
317
346
  state.timer = null;
318
347
  tip.hidden = false;
319
348
  repositionTip(ref, tip);
349
+ startTracking(tip, state);
320
350
  };
321
351
 
322
352
  if (immediate) reveal();
@@ -339,7 +369,12 @@ function handleTriggerIntent(e) {
339
369
  return;
340
370
 
341
371
  const tip = ensureTip(trigger);
342
- if (tip) show(tip, trigger);
372
+ if (!tip) return;
373
+
374
+ const state = tipStates.get(tip);
375
+ if (e.type === "focusin") state.focused = true;
376
+ else state.hovered = true;
377
+ show(tip, trigger);
343
378
  }
344
379
 
345
380
  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