actual-css 0.10.0 → 0.11.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 (71) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +2 -2
  3. package/components.json +2 -1
  4. package/dist/actual.full.css +314 -106
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/enhancement-contract.md +16 -7
  8. package/docs/design-notes/platform-alignment.md +8 -4
  9. package/docs/design-notes/rich-text.md +61 -0
  10. package/docs/design-notes/steps.md +2 -2
  11. package/docs/navigation.json +2 -0
  12. package/docs/pages/components/aura.md +12 -13
  13. package/docs/pages/components/badge.md +10 -0
  14. package/docs/pages/components/button.md +1 -1
  15. package/docs/pages/components/chat.md +5 -0
  16. package/docs/pages/components/dialog.md +5 -2
  17. package/docs/pages/components/pagination.md +15 -15
  18. package/docs/pages/components/shimmer.md +57 -0
  19. package/docs/pages/components/skeleton.md +2 -1
  20. package/docs/pages/components/spinner.md +11 -6
  21. package/docs/pages/components/table.md +13 -0
  22. package/docs/pages/enhancements/flyout.md +2 -0
  23. package/docs/pages/enhancements/tabs.md +4 -0
  24. package/docs/pages/forms/choice-cards.md +77 -4
  25. package/docs/pages/forms/combobox.md +16 -1
  26. package/docs/pages/forms/overview.md +4 -1
  27. package/docs/pages/forms/range.md +110 -5
  28. package/docs/pages/forms/rich-text.md +110 -0
  29. package/docs/pages/forms/validation.md +5 -1
  30. package/docs/pages/foundations/theming.md +49 -3
  31. package/docs/pages/foundations/tokens.md +15 -5
  32. package/docs/pages/guides/bootstrap.md +2 -1
  33. package/docs/pages/guides/introduction.md +1 -1
  34. package/docs/pages/guides/modular-import.md +1 -1
  35. package/docs/pages/guides/progressive-enhancement.md +13 -14
  36. package/docs/pages/guides/tailwind.md +4 -2
  37. package/llms.txt +4 -3
  38. package/package.json +5 -1
  39. package/reserved-classes.json +1 -0
  40. package/src/css/components/avatar.css +1 -1
  41. package/src/css/components/flyout.css +4 -1
  42. package/src/css/components/meter.css +1 -1
  43. package/src/css/components/pagination.css +3 -2
  44. package/src/css/components/rating.css +1 -1
  45. package/src/css/components/status-bar.css +2 -2
  46. package/src/css/components/steps.css +3 -3
  47. package/src/css/components/tab.css +3 -3
  48. package/src/css/components/table.css +6 -0
  49. package/src/css/components/tooltip.css +1 -1
  50. package/src/css/core/focus.css +14 -4
  51. package/src/css/core/theme.css +8 -0
  52. package/src/css/core/tokens.css +8 -4
  53. package/src/css/core/variants.css +2 -1
  54. package/src/css/effects/aura.css +51 -33
  55. package/src/css/effects/index.css +1 -0
  56. package/src/css/effects/shimmer.css +57 -0
  57. package/src/css/forms/choice-card.css +71 -23
  58. package/src/css/forms/control.css +2 -2
  59. package/src/css/forms/otp.css +9 -1
  60. package/src/css/forms/range.css +64 -21
  61. package/src/js/command.js +61 -32
  62. package/src/js/dialog.js +8 -23
  63. package/src/js/dismiss.js +1 -2
  64. package/src/js/enhance.js +84 -123
  65. package/src/js/full.js +1 -0
  66. package/src/js/password.js +1 -8
  67. package/src/js/range.js +151 -0
  68. package/src/js/scrollspy.js +4 -5
  69. package/src/js/status.js +2 -10
  70. package/src/js/tooltip.js +11 -1
  71. package/src/js/validation.js +4 -10
package/src/js/enhance.js CHANGED
@@ -1,8 +1,13 @@
1
1
  /*
2
2
  * Enhance — DOM lifecycle engine for behavioral enhancers.
3
3
  *
4
- * One MutationObserver per root watches DOM insertions/removals. Multiple calls
5
- * to enhance() share that observer while keeping separate teardown handles.
4
+ * One MutationObserver per root watches DOM insertions/removals and serves
5
+ * every enhance() and registerEnhancement() record on that root, each with
6
+ * its own teardown handle. A record pairs a discovery query with
7
+ * selector-keyed initialisers: enhance() queries its own selectors; a named
8
+ * enhancement keys `[data-enhance~="name"]` behind the shared
9
+ * `[data-enhance]` query, so any number of named behaviors cost the root a
10
+ * single discovery gate.
6
11
  *
7
12
  * Sweep (not removedNodes scanning) is the key design choice: it reasons
8
13
  * about final state after a batch, so a moved element (removed then
@@ -16,16 +21,9 @@
16
21
  * attribute already set. Behavior attributes are setup-time contracts, not
17
22
  * live enable/disable switches.
18
23
  *
19
- * enhancers: { [selector]: (el) => cleanup | void }
20
- * Returns: { refresh, disconnect }
21
- *
22
- * Enhancement tokens — the data-enhance layer on top of enhance().
23
- *
24
- * enhancementSelector("tabs") returns [data-enhance~="tabs"], the generic
25
- * opt-in for root-controller behaviors. registerEnhancement() owns a name per
26
- * root — a second registration for the same name on the same root throws, and
27
- * disconnect() releases ownership. Third-party behaviors register exactly
28
- * like built-in ones.
24
+ * registerEnhancement() owns a name per root — a second registration for the
25
+ * same name on the same root throws, and disconnect() releases ownership.
26
+ * Third-party behaviors register exactly like built-in ones.
29
27
  *
30
28
  * See docs/design-notes/enhancement-contract.md.
31
29
  */
@@ -39,6 +37,8 @@ const ELEMENT_NODE = 1;
39
37
  const DOCUMENT_NODE = 9;
40
38
  const DOCUMENT_FRAGMENT_NODE = 11;
41
39
 
40
+ const ENHANCEMENT_NAME = /^[a-z][a-z0-9-]*$/;
41
+
42
42
  function noopRuntime() {
43
43
  return {
44
44
  refresh() {},
@@ -56,50 +56,30 @@ function canScan(node) {
56
56
 
57
57
  function createRegistry(root) {
58
58
  const records = new Set();
59
- let selectorString = "";
60
-
61
- function isValidSelector(selector) {
62
- try {
63
- root.querySelector?.(selector);
64
- if (root.nodeType === ELEMENT_NODE) root.matches(selector);
65
- return true;
66
- } catch (error) {
67
- console.error(`Invalid enhancer selector "${selector}"`, error);
68
- return false;
69
- }
70
- }
71
-
72
- function updateSelectorString() {
73
- selectorString = [...records].flatMap((record) => record.selectors).join(",");
74
- }
59
+ let discovery = "";
75
60
 
76
61
  function start(record, el) {
77
- if (el?.nodeType !== ELEMENT_NODE) return;
78
62
  let active = record.instances.get(el);
79
-
80
- for (const selector of record.selectors) {
81
- if (!el.matches(selector)) continue;
82
- if (active?.has(selector)) continue;
63
+ for (const key of record.keys) {
64
+ if (active?.has(key) || !el.matches(key)) continue;
83
65
 
84
66
  let cleanup;
85
67
  try {
86
- cleanup = record.enhancers[selector](el);
68
+ cleanup = record.enhancers[key](el);
87
69
  } catch (error) {
88
- console.error(`Enhancer failed for selector "${selector}"`, error);
70
+ console.error(`Enhancer "${key}" failed`, error);
89
71
  continue;
90
72
  }
91
73
  if (!active) {
92
74
  active = new Map();
93
75
  record.instances.set(el, active);
94
76
  }
95
- active.set(selector, typeof cleanup === "function" ? cleanup : null);
77
+ active.set(key, typeof cleanup === "function" ? cleanup : null);
96
78
  }
97
79
  }
98
80
 
99
81
  function stop(record, el) {
100
- const active = record.instances.get(el);
101
- if (!active) return;
102
- for (const cleanup of active.values()) {
82
+ for (const cleanup of record.instances.get(el)?.values() ?? []) {
103
83
  try {
104
84
  cleanup?.();
105
85
  } catch (error) {
@@ -109,24 +89,13 @@ function createRegistry(root) {
109
89
  record.instances.delete(el);
110
90
  }
111
91
 
112
- function scanFor(record, node) {
113
- if (!canScan(node)) return;
114
- if (node.nodeType === ELEMENT_NODE) start(record, node);
115
- if (record.selectorString) {
116
- node.querySelectorAll?.(record.selectorString).forEach((el) => {
117
- start(record, el);
118
- });
119
- }
120
- }
121
-
122
- function scan(node) {
123
- if (!canScan(node) || !selectorString) return;
124
- if (node.nodeType === ELEMENT_NODE) {
125
- for (const record of records) start(record, node);
126
- }
127
- node.querySelectorAll?.(selectorString).forEach((el) => {
128
- for (const record of records) start(record, el);
129
- });
92
+ function scan(node, targets, selector) {
93
+ if (!canScan(node) || !selector) return;
94
+ const visit = (el) => {
95
+ for (const record of targets) start(record, el);
96
+ };
97
+ if (node.nodeType === ELEMENT_NODE) visit(node);
98
+ node.querySelectorAll(selector).forEach(visit);
130
99
  }
131
100
 
132
101
  function sweepDisconnected() {
@@ -137,11 +106,16 @@ function createRegistry(root) {
137
106
  }
138
107
  }
139
108
 
109
+ function updateDiscovery() {
110
+ // Named records all share `[data-enhance]`; the Set collapses them.
111
+ discovery = [...new Set(Array.from(records, (record) => record.query))].join(",");
112
+ }
113
+
140
114
  const observer = new MutationObserver((mutationRecords) => {
141
115
  let hasRemoval = false;
142
116
  for (const mutationRecord of mutationRecords) {
143
117
  for (const node of mutationRecord.addedNodes) {
144
- scan(node);
118
+ scan(node, records, discovery);
145
119
  }
146
120
  if (mutationRecord.removedNodes.length > 0) {
147
121
  hasRemoval = true;
@@ -151,61 +125,52 @@ function createRegistry(root) {
151
125
  });
152
126
 
153
127
  return {
154
- add(enhancers) {
155
- const selectors = Object.keys(enhancers).filter(isValidSelector);
156
- if (!selectors.length) return null;
157
- const validEnhancers = Object.fromEntries(
158
- selectors.map((selector) => [selector, enhancers[selector]]),
159
- );
160
-
161
- const record = {
162
- disconnected: false,
163
- enhancers: validEnhancers,
164
- instances: new Map(),
165
- selectors,
166
- selectorString: selectors.join(","),
167
- };
128
+ add(spec) {
129
+ const record = { ...spec, disconnected: false, instances: new Map() };
168
130
  records.add(record);
169
131
  // Observe only once there is a record to act on; an enhance() call with
170
132
  // no valid selector must not leave an idle observer behind.
171
133
  if (records.size === 1) observer.observe(root, { childList: true, subtree: true });
172
- updateSelectorString();
173
- scanFor(record, root);
174
- return record;
175
- },
176
- refresh(record, node) {
177
- if (record.disconnected) return;
178
- scanFor(record, node);
179
- },
180
- remove(record) {
181
- if (record.disconnected) return;
182
- record.disconnected = true;
183
- for (const el of Array.from(record.instances.keys())) {
184
- stop(record, el);
185
- }
186
- records.delete(record);
187
- updateSelectorString();
188
- if (records.size === 0) {
189
- observer.disconnect();
190
- registries.delete(root);
191
- }
134
+ updateDiscovery();
135
+ scan(root, [record], record.query);
136
+
137
+ return {
138
+ refresh(node) {
139
+ if (!record.disconnected) scan(node, [record], record.query);
140
+ },
141
+ disconnect() {
142
+ if (record.disconnected) return;
143
+ record.disconnected = true;
144
+ for (const el of Array.from(record.instances.keys())) stop(record, el);
145
+ records.delete(record);
146
+ updateDiscovery();
147
+ if (records.size) return;
148
+ observer.disconnect();
149
+ registries.delete(root);
150
+ },
151
+ };
192
152
  },
193
153
  };
194
154
  }
195
155
 
196
- const ENHANCEMENT_NAME = /^[a-z][a-z0-9-]*$/;
156
+ function registryFor(root) {
157
+ let registry = registries.get(root);
158
+ if (!registry) {
159
+ registry = createRegistry(root);
160
+ registries.set(root, registry);
161
+ }
162
+ return registry;
163
+ }
197
164
 
198
- export function enhancementSelector(name) {
165
+ // The name check is what keeps the interpolation safe: a quote or space in a
166
+ // name would otherwise break or widen the selector.
167
+ function enhancementSelector(name) {
199
168
  if (!ENHANCEMENT_NAME.test(name)) {
200
169
  throw new TypeError(`Invalid enhancement name: ${name}`);
201
170
  }
202
171
  return `[data-enhance~="${name}"]`;
203
172
  }
204
173
 
205
- export function hasEnhancement(el, name) {
206
- return el.matches(enhancementSelector(name));
207
- }
208
-
209
174
  /**
210
175
  * Apply a named opt-in once; presentation selectors remain application-owned.
211
176
  * @param {string} name
@@ -216,12 +181,12 @@ export function hasEnhancement(el, name) {
216
181
  export function applyEnhancement(name, selector, root) {
217
182
  if (typeof document === "undefined") return [];
218
183
 
219
- enhancementSelector(name);
184
+ const enhanced = enhancementSelector(name);
220
185
  root ??= document.documentElement;
221
186
  const elements = [...root.querySelectorAll(selector)];
222
187
 
223
188
  for (const el of elements) {
224
- if (hasEnhancement(el, name)) continue;
189
+ if (el.matches(enhanced)) continue;
225
190
  const tokens = el.getAttribute("data-enhance");
226
191
  el.setAttribute("data-enhance", tokens ? `${tokens} ${name}` : name);
227
192
  }
@@ -240,7 +205,7 @@ export function applyEnhancement(name, selector, root) {
240
205
  export function registerEnhancement(name, init, root) {
241
206
  if (typeof document === "undefined") return noopRuntime();
242
207
 
243
- enhancementSelector(name);
208
+ const selector = enhancementSelector(name);
244
209
  if (typeof init !== "function") {
245
210
  throw new TypeError("registerEnhancement() requires a function init.");
246
211
  }
@@ -255,7 +220,11 @@ export function registerEnhancement(name, init, root) {
255
220
  throw new Error(`Enhancement "${name}" is already registered on this root.`);
256
221
  }
257
222
 
258
- const runtime = enhance({ [enhancementSelector(name)]: init }, root);
223
+ const runtime = registryFor(root).add({
224
+ keys: [selector],
225
+ enhancers: { [selector]: init },
226
+ query: "[data-enhance]",
227
+ });
259
228
  names.set(name, runtime);
260
229
 
261
230
  return {
@@ -268,34 +237,26 @@ export function registerEnhancement(name, init, root) {
268
237
  };
269
238
  }
270
239
 
271
- function registryFor(root) {
272
- let registry = registries.get(root);
273
- if (!registry) {
274
- registry = createRegistry(root);
275
- registries.set(root, registry);
276
- }
277
- return registry;
278
- }
279
-
280
240
  /**
281
241
  * @param {Record<string, (el: Element) => (() => void) | void>} enhancers
282
242
  * @param {Document | Element | DocumentFragment} [root]
283
243
  * @returns {{ refresh: (node: Node) => void, disconnect: () => void }}
284
244
  */
285
245
  export default function enhance(enhancers, root) {
286
- if (typeof document === "undefined") {
287
- return noopRuntime();
288
- }
246
+ if (typeof document === "undefined") return noopRuntime();
289
247
 
290
248
  root ??= document.documentElement;
291
- const registry = registryFor(root);
292
- const record = registry.add(enhancers);
293
- if (!record) return noopRuntime();
249
+ const keys = Object.keys(enhancers).filter((selector) => {
250
+ try {
251
+ root.querySelector?.(selector);
252
+ if (root.nodeType === ELEMENT_NODE) root.matches(selector);
253
+ return true;
254
+ } catch (error) {
255
+ console.error(`Invalid enhancer selector "${selector}"`, error);
256
+ return false;
257
+ }
258
+ });
259
+ if (!keys.length) return noopRuntime();
294
260
 
295
- return {
296
- refresh: (node) => registry.refresh(record, node),
297
- disconnect: () => {
298
- registry.remove(record);
299
- },
300
- };
261
+ return registryFor(root).add({ keys, enhancers, query: keys.join(",") });
301
262
  }
package/src/js/full.js CHANGED
@@ -21,6 +21,7 @@ import "./scrollspy.js";
21
21
  import "./filter.js";
22
22
  import "./mask.js";
23
23
  import "./password.js";
24
+ import "./range.js";
24
25
  import "./validation.js";
25
26
  import "./status.js";
26
27
  import "./index.js";
@@ -74,14 +74,7 @@ function watchSubmitRoot(input) {
74
74
 
75
75
  registerCommands(PASSWORD_COMMANDS, {
76
76
  resolve: resolvePasswordInput,
77
- prepare: (trigger, input) => {
78
- trigger.setAttribute("aria-controls", input.id);
79
- if (!trigger.hasAttribute("aria-pressed")) {
80
- trigger.setAttribute("aria-pressed", "false");
81
- }
82
- },
83
- handle: (event, _trigger, input) => {
84
- event.preventDefault();
77
+ handle: (_event, _trigger, input) => {
85
78
  const reveal = input.type === "password";
86
79
  if (reveal) watchSubmitRoot(input);
87
80
  setRevealed(input, reveal);
@@ -0,0 +1,151 @@
1
+ /*
2
+ * Range — fill share and named-value semantics for range inputs.
3
+ *
4
+ * The `range` enhancement token is the opt-in; it stays independent from
5
+ * the `.range` presentation class:
6
+ *
7
+ * <input class="range" data-enhance="range" type="range" min="0" max="100">
8
+ *
9
+ * Every opted-in input gets `--range-progress`, the fill share the track
10
+ * gradient in range.css reads. Without this module the variable stays at
11
+ * its `0%` default and the track renders plain — the control is fully
12
+ * usable, only unfilled.
13
+ *
14
+ * When the input references a datalist whose option carries a `label`, the
15
+ * module also sets `aria-valuetext` (the announced value when the numeric
16
+ * value really means a text, e.g. `2` means "High") and mirrors the label
17
+ * into each associated `<output for="…">`. Those writes are owned: when a
18
+ * later value has no label, the module restores the pre-existing
19
+ * `aria-valuetext` (or removes the attribute if it was absent) and each
20
+ * output's original text — but only where the current value is still the
21
+ * module's own last write, so author state is never destroyed.
22
+ * Options without a label leave author-written `aria-valuetext` and outputs
23
+ * untouched.
24
+ *
25
+ * <output for="effort">High</output>
26
+ * <input data-enhance="range" type="range" id="effort" min="0" max="3" list="effort-levels" aria-label="Effort">
27
+ */
28
+
29
+ import { registerEnhancement } from "./enhance.js";
30
+
31
+ const RANGE_SELECTOR = 'input[type="range"]';
32
+
33
+ // Writes this module owns per input: for the valuetext and each output,
34
+ // the value before the first write (original) and the last value written
35
+ // (last). A later unlabeled value restores an entry only when its current
36
+ // value is still the module's last write — author state, whether present
37
+ // from the start or written since, is never destroyed. Entries live on the
38
+ // node, not the tree, so a disconnected output is still restored.
39
+ const owned = new WeakMap();
40
+
41
+ function toFinite(value, fallback) {
42
+ // Absent min/max read back as "" (Number("") is 0, not missing).
43
+ if (value === "" || value === null || value === undefined) return fallback;
44
+ const parsed = Number(value);
45
+ return Number.isFinite(parsed) ? parsed : fallback;
46
+ }
47
+
48
+ function labelFor(input) {
49
+ const listId = input.getAttribute("list");
50
+ if (!listId) return undefined;
51
+ const list = input.getRootNode().getElementById?.(listId);
52
+ if (!list) return undefined;
53
+ const option = [...(list.options ?? [])].find((entry) => entry.value === input.value);
54
+ const label = option?.label ?? option?.getAttribute?.("label") ?? "";
55
+ return label ? label : undefined;
56
+ }
57
+
58
+ function outputsFor(input) {
59
+ if (!input.id) return [];
60
+ const root = input.getRootNode();
61
+ if (typeof root.querySelectorAll !== "function") return [];
62
+ return [...root.querySelectorAll(`output[for~="${CSS.escape(input.id)}"]`)];
63
+ }
64
+
65
+ function syncRange(input) {
66
+ const min = toFinite(input.min, 0);
67
+ const max = toFinite(input.max, 100);
68
+ const value = toFinite(input.value, min);
69
+ const share = max > min ? ((value - min) / (max - min)) * 100 : 0;
70
+ const clamped = Math.min(100, Math.max(0, share));
71
+ input.style.setProperty("--range-progress", `${clamped}%`);
72
+
73
+ const label = labelFor(input);
74
+ if (label === undefined) {
75
+ releaseOwned(input);
76
+ return;
77
+ }
78
+ const record = owned.get(input) ?? { aria: null, outputs: new Map() };
79
+ if (!record.aria) {
80
+ record.aria = { original: input.getAttribute("aria-valuetext"), last: label };
81
+ } else {
82
+ record.aria.last = label;
83
+ }
84
+ input.setAttribute("aria-valuetext", label);
85
+ for (const output of outputsFor(input)) {
86
+ const entry = record.outputs.get(output) ?? { original: output.textContent, last: label };
87
+ entry.last = label;
88
+ record.outputs.set(output, entry);
89
+ output.textContent = label;
90
+ }
91
+ owned.set(input, record);
92
+ }
93
+
94
+ function releaseOwned(input) {
95
+ const record = owned.get(input);
96
+ if (!record) return;
97
+ // Restore only what this module wrote: an author may have set its own
98
+ // value since, and that state is not ours to destroy. A null original
99
+ // means the attribute was absent before the first write.
100
+ if (record.aria && input.getAttribute("aria-valuetext") === record.aria.last) {
101
+ if (record.aria.original === null) {
102
+ input.removeAttribute("aria-valuetext");
103
+ } else {
104
+ input.setAttribute("aria-valuetext", record.aria.original);
105
+ }
106
+ }
107
+ for (const [output, entry] of record.outputs) {
108
+ if (output.textContent === entry.last) output.textContent = entry.original;
109
+ }
110
+ owned.delete(input);
111
+ }
112
+
113
+ // Private on purpose: `owned` holds one record per input, so a second,
114
+ // independent connection would restore and drop state the enhancement still
115
+ // owns. The lifecycle is the only caller. A programmatic value change goes
116
+ // through dispatchInput() from ./input.js, which every listener sees.
117
+ function connectRange(input) {
118
+ const controller = new AbortController();
119
+ syncRange(input);
120
+ input.addEventListener("input", () => syncRange(input), {
121
+ signal: controller.signal,
122
+ });
123
+ // Listen on the root so form="…" and moves between forms keep working.
124
+ input.getRootNode().addEventListener(
125
+ "reset",
126
+ (event) => {
127
+ if (event.target !== input.form) return;
128
+ // Reset fires before native values change. A new task also lets later
129
+ // listeners cancel it; a microtask can run before the native reset.
130
+ setTimeout(() => {
131
+ if (
132
+ !controller.signal.aborted &&
133
+ !event.defaultPrevented &&
134
+ input.isConnected &&
135
+ input.form === event.target
136
+ ) {
137
+ syncRange(input);
138
+ }
139
+ }, 0);
140
+ },
141
+ { capture: true, signal: controller.signal },
142
+ );
143
+ return () => {
144
+ releaseOwned(input);
145
+ controller.abort();
146
+ };
147
+ }
148
+
149
+ registerEnhancement("range", (input) =>
150
+ input.matches(RANGE_SELECTOR) ? connectRange(input) : undefined,
151
+ );
@@ -154,11 +154,10 @@ function setupNav(nav) {
154
154
  measure();
155
155
  }
156
156
 
157
- // The scroll root (custom container or the document viewport) is resolved
158
- // once at connect and is stable for the lifetime of the connection; measure
159
- // still re-reads the current root element so a replaced *element* under a
160
- // stable container is handled, but swapping the container itself is out of
161
- // contract (re-inject the nav or call refreshScrollspy after such a move).
157
+ // The scroll root is resolved once when the enhancement connects, and the
158
+ // scroll listener stays on it. Changing data-scrollspy-root or replacing its
159
+ // target is outside the connection contract; recreate the enhancement with
160
+ // the new markup. refreshScrollspy() only rebuilds the link->section map.
162
161
  const scrollTarget = rootFor(nav) ?? nav.ownerDocument.defaultView;
163
162
  scrollTarget?.addEventListener("scroll", () => schedule(), {
164
163
  passive: true,
package/src/js/status.js CHANGED
@@ -247,10 +247,6 @@ function resolveStatusTrigger(trigger) {
247
247
  return target && target === statusTarget() ? target : null;
248
248
  }
249
249
 
250
- function connectStatusTrigger(trigger, target) {
251
- trigger.setAttribute("aria-controls", target.id);
252
- }
253
-
254
250
  if (typeof document !== "undefined") {
255
251
  document.addEventListener(EVENTS.invalid, (event) => {
256
252
  const message = event.detail?.message;
@@ -273,9 +269,7 @@ if (typeof document !== "undefined") {
273
269
 
274
270
  registerCommands(STATUS_SHOW_COMMANDS, {
275
271
  resolve: resolveStatusTrigger,
276
- prepare: connectStatusTrigger,
277
- handle: (event, trigger) => {
278
- event.preventDefault();
272
+ handle: (_event, trigger) => {
279
273
  dispatchStatusEvent(trigger, {
280
274
  message: trigger.getAttribute("data-status-message"),
281
275
  intent: trigger.getAttribute("data-status-intent") ?? undefined,
@@ -286,9 +280,7 @@ if (typeof document !== "undefined") {
286
280
 
287
281
  registerCommands(STATUS_CLEAR_COMMANDS, {
288
282
  resolve: resolveStatusTrigger,
289
- prepare: connectStatusTrigger,
290
- handle: (event, trigger) => {
291
- event.preventDefault();
283
+ handle: (_event, trigger) => {
292
284
  dispatchStatusEvent(trigger, {});
293
285
  },
294
286
  });
package/src/js/tooltip.js CHANGED
@@ -29,7 +29,10 @@
29
29
  * tooltips therefore keep their initial text; use an explicit
30
30
  * tooltip when its content must update dynamically.
31
31
  *
32
- * Show: hover + focus (150ms delay), click toggle, or always visible
32
+ * Show: hover + keyboard focus (150ms delay), click toggle, or always
33
+ * visible. Focus counts only when it matches :focus-visible, so
34
+ * a pointer press, or focus handed back by a closing dialog after
35
+ * a pointer close, shows nothing.
33
36
  * Hide: focus leaving the trigger (focusout), pointer leave, Escape
34
37
  *
35
38
  * Geometry: a trigger scrolled out of the positioning boundary takes its tip
@@ -519,6 +522,13 @@ function handleTriggerIntent(e) {
519
522
  )
520
523
  return;
521
524
 
525
+ // why: focus is a show intent only when it is keyboard focus. A pointer
526
+ // press focuses the trigger too, and a dialog or drawer closed with the
527
+ // pointer hands focus back to its invoker; neither asks for a label the
528
+ // user just acted on. :focus-visible is the browser's own modality verdict,
529
+ // the one the focus rings already follow, so Tab still shows the tip.
530
+ if (e.type === "focusin" && !e.target.matches(":focus-visible")) return;
531
+
522
532
  const tip = ensureTip(trigger);
523
533
  if (!tip) return;
524
534
 
@@ -11,19 +11,20 @@
11
11
  * Opt in with data-enhance="validation". The .needs-validation class
12
12
  * remains valid for presentation-only (CSS :user-invalid feedback without
13
13
  * the JS behavior). Importing the module registers the behavior; there is
14
- * no init call.
14
+ * no init call. To opt existing forms in by selector, use
15
+ * applyEnhancement("validation", selector) so they join the enhance()
16
+ * lifecycle (inserted forms connect, removed forms tear down).
15
17
  *
16
18
  * The marker is a static, init-time contract. Removing the form from the
17
19
  * DOM tears down everything, including the managed novalidate attribute.
18
20
  */
19
21
 
20
- import { enhancementSelector, registerEnhancement } from "./enhance.js";
22
+ import { registerEnhancement } from "./enhance.js";
21
23
  import { EVENTS } from "./events.js";
22
24
  import { CLASSES } from "./selectors.js";
23
25
 
24
26
  const NOVALIDATE = "novalidate";
25
27
  const WAS_VALIDATED_CLASS = CLASSES.wasValidated;
26
- const VALIDATION_SELECTOR = enhancementSelector("validation");
27
28
  const FIELD_CLASS = CLASSES.field;
28
29
  const DANGER_CLASS = CLASSES.danger;
29
30
  const MANAGED_NOVALIDATE_ATTR = "validationManagedNovalidate";
@@ -398,13 +399,6 @@ export class FormValidator {
398
399
  rules[name] = callback;
399
400
  }
400
401
 
401
- static init(selector = `form${VALIDATION_SELECTOR}`) {
402
- if (typeof document === "undefined") return;
403
- for (const form of document.querySelectorAll(selector)) {
404
- if (isFormElement(form)) connectForm(form);
405
- }
406
- }
407
-
408
402
  static setFieldError(el, message) {
409
403
  el.setCustomValidity(message || "invalid");
410
404
  el.dataset.validationErrors = "server";