actual-css 0.9.2 → 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 (136) hide show
  1. package/CHANGELOG.md +169 -1
  2. package/README.md +65 -44
  3. package/components.json +35 -14
  4. package/dist/actual.full.css +877 -583
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/app-ui.md +1 -1
  8. package/docs/design-notes/column-layout.md +103 -520
  9. package/docs/design-notes/enhancement-contract.md +16 -7
  10. package/docs/design-notes/forced-colors.md +12 -1
  11. package/docs/design-notes/naming.md +1 -1
  12. package/docs/design-notes/platform-alignment.md +8 -4
  13. package/docs/design-notes/rich-text.md +61 -0
  14. package/docs/design-notes/steps.md +2 -2
  15. package/docs/design-notes/surface-reparenting.md +8 -9
  16. package/docs/navigation.json +4 -0
  17. package/docs/pages/components/accordion.md +55 -1
  18. package/docs/pages/components/alert.md +9 -15
  19. package/docs/pages/components/aura.md +12 -13
  20. package/docs/pages/components/badge.md +16 -7
  21. package/docs/pages/components/bleed.md +42 -0
  22. package/docs/pages/components/button.md +30 -4
  23. package/docs/pages/components/card.md +21 -86
  24. package/docs/pages/components/chat.md +5 -0
  25. package/docs/pages/components/close.md +48 -0
  26. package/docs/pages/components/data-list.md +26 -1
  27. package/docs/pages/components/dialog.md +21 -18
  28. package/docs/pages/components/drawer.md +9 -9
  29. package/docs/pages/components/navbar.md +5 -5
  30. package/docs/pages/components/pagination.md +15 -15
  31. package/docs/pages/components/shimmer.md +57 -0
  32. package/docs/pages/components/skeleton.md +2 -1
  33. package/docs/pages/components/spinner.md +14 -9
  34. package/docs/pages/components/table.md +13 -0
  35. package/docs/pages/enhancements/flyout.md +2 -0
  36. package/docs/pages/enhancements/overview.md +5 -0
  37. package/docs/pages/enhancements/tabs.md +4 -0
  38. package/docs/pages/enhancements/tooltip.md +3 -1
  39. package/docs/pages/examples/overview.md +2 -1
  40. package/docs/pages/forms/choice-cards.md +116 -4
  41. package/docs/pages/forms/combobox.md +39 -12
  42. package/docs/pages/forms/joined-controls.md +2 -5
  43. package/docs/pages/forms/overview.md +10 -1
  44. package/docs/pages/forms/range.md +110 -5
  45. package/docs/pages/forms/rich-text.md +110 -0
  46. package/docs/pages/forms/validation.md +5 -1
  47. package/docs/pages/foundations/theming.md +110 -18
  48. package/docs/pages/foundations/tokens.md +41 -13
  49. package/docs/pages/guides/bootstrap.md +2 -1
  50. package/docs/pages/guides/introduction.md +15 -23
  51. package/docs/pages/guides/modular-import.md +54 -7
  52. package/docs/pages/guides/progressive-enhancement.md +19 -17
  53. package/docs/pages/guides/tailwind.md +21 -7
  54. package/docs/pages/layout/column-layout.md +92 -54
  55. package/docs/pages/layout/media.md +1 -1
  56. package/docs/pages/layout/overview.md +1 -1
  57. package/docs/pages/utilities/overview.md +1 -1
  58. package/docs/pages/utilities/sizing-wrapping.md +5 -3
  59. package/llms.txt +25 -4
  60. package/package.json +10 -6
  61. package/reserved-classes.json +6 -30
  62. package/skills/actual-css/PROJECT.md.example +65 -0
  63. package/skills/actual-css/README.md +64 -0
  64. package/skills/actual-css/SKILL.md +234 -0
  65. package/skills/actual-css/references/accessibility.md +44 -0
  66. package/skills/actual-css/references/api-and-vocabulary.md +94 -0
  67. package/skills/actual-css/references/enhancements.md +59 -0
  68. package/skills/actual-css/references/forms.md +49 -0
  69. package/skills/actual-css/references/layout-and-theming.md +82 -0
  70. package/skills/actual-css/references/review-checklist.md +51 -0
  71. package/skills/actual-css/scripts/inspect-actual-css.mjs +171 -0
  72. package/src/css/components/accordion.css +44 -7
  73. package/src/css/components/alert.css +23 -43
  74. package/src/css/components/app-nav.css +9 -0
  75. package/src/css/components/avatar.css +1 -1
  76. package/src/css/components/badge.css +11 -39
  77. package/src/css/components/bleed.css +79 -0
  78. package/src/css/components/button.css +6 -17
  79. package/src/css/components/card.css +7 -40
  80. package/src/css/components/close.css +91 -0
  81. package/src/css/components/data-list.css +18 -0
  82. package/src/css/components/drawer.css +15 -56
  83. package/src/css/components/fab.css +10 -0
  84. package/src/css/components/flyout.css +5 -1
  85. package/src/css/components/index.css +2 -0
  86. package/src/css/components/join.css +36 -20
  87. package/src/css/components/menu.css +12 -0
  88. package/src/css/components/meter.css +1 -1
  89. package/src/css/components/modal.css +10 -57
  90. package/src/css/components/navbar.css +27 -0
  91. package/src/css/components/pagination.css +3 -2
  92. package/src/css/components/rating.css +1 -1
  93. package/src/css/components/status-bar.css +2 -2
  94. package/src/css/components/steps.css +3 -3
  95. package/src/css/components/tab.css +3 -3
  96. package/src/css/components/table.css +6 -0
  97. package/src/css/components/tooltip.css +1 -1
  98. package/src/css/core/focus.css +19 -3
  99. package/src/css/core/theme.css +33 -43
  100. package/src/css/core/tokens.css +9 -5
  101. package/src/css/core/variants.css +5 -34
  102. package/src/css/effects/aura.css +51 -33
  103. package/src/css/effects/index.css +1 -0
  104. package/src/css/effects/shimmer.css +57 -0
  105. package/src/css/forms/choice-card.css +142 -24
  106. package/src/css/forms/choice.css +2 -3
  107. package/src/css/forms/control.css +17 -10
  108. package/src/css/forms/custom-select.css +4 -4
  109. package/src/css/forms/native.css +4 -4
  110. package/src/css/forms/otp.css +9 -1
  111. package/src/css/forms/range.css +72 -14
  112. package/src/css/forms/switch.css +2 -3
  113. package/src/css/layout/app-layout.css +1 -1
  114. package/src/css/layout/column-layout.css +26 -111
  115. package/src/css/utilities/extra.css +7 -6
  116. package/src/js/command.js +61 -32
  117. package/src/js/dialog.js +103 -47
  118. package/src/js/dismiss.js +1 -2
  119. package/src/js/enhance.js +91 -126
  120. package/src/js/escape.js +1 -0
  121. package/src/js/flyout.js +10 -1
  122. package/src/js/focus-group.js +2 -1
  123. package/src/js/full.js +1 -0
  124. package/src/js/keys.js +14 -0
  125. package/src/js/menu.js +2 -1
  126. package/src/js/password.js +1 -8
  127. package/src/js/range.js +151 -0
  128. package/src/js/scrollspy.js +4 -5
  129. package/src/js/status.js +21 -10
  130. package/src/js/surface.js +12 -8
  131. package/src/js/tab.js +2 -1
  132. package/src/js/tooltip.js +29 -2
  133. package/src/js/validation.js +8 -15
  134. package/dist/actual.css +0 -876
  135. package/dist/actual.js +0 -1
  136. package/dist/actual.min.css +0 -1
package/src/js/enhance.js CHANGED
@@ -1,27 +1,29 @@
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
9
14
  * reinserted in the same batch) survives without a spurious disconnect.
10
15
  *
11
- * Attribute changes are intentionally not observed. Call refresh(node) after
12
- * adding a behavior attribute to an already-connected element. Behavior
13
- * attributes are setup-time contracts, not live enable/disable switches.
16
+ * Attribute changes are intentionally not observed. The owner of a handle
17
+ * calls refresh(node) after adding a behavior attribute to an
18
+ * already-connected element; for a data-enhance token, applyEnhancement()
19
+ * does it. The built-in data-* behaviors (mask, filter, context menu, …)
20
+ * keep their handle private, so for them insert the element with the
21
+ * attribute already set. Behavior attributes are setup-time contracts, not
22
+ * live enable/disable switches.
14
23
  *
15
- * enhancers: { [selector]: (el) => cleanup | void }
16
- * Returns: { refresh, disconnect }
17
- *
18
- * Enhancement tokens — the data-enhance layer on top of enhance().
19
- *
20
- * enhancementSelector("tabs") returns [data-enhance~="tabs"], the generic
21
- * opt-in for root-controller behaviors. registerEnhancement() owns a name per
22
- * root — a second registration for the same name on the same root throws, and
23
- * disconnect() releases ownership. Third-party behaviors register exactly
24
- * 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.
25
27
  *
26
28
  * See docs/design-notes/enhancement-contract.md.
27
29
  */
@@ -35,6 +37,8 @@ const ELEMENT_NODE = 1;
35
37
  const DOCUMENT_NODE = 9;
36
38
  const DOCUMENT_FRAGMENT_NODE = 11;
37
39
 
40
+ const ENHANCEMENT_NAME = /^[a-z][a-z0-9-]*$/;
41
+
38
42
  function noopRuntime() {
39
43
  return {
40
44
  refresh() {},
@@ -52,50 +56,30 @@ function canScan(node) {
52
56
 
53
57
  function createRegistry(root) {
54
58
  const records = new Set();
55
- let selectorString = "";
56
-
57
- function isValidSelector(selector) {
58
- try {
59
- root.querySelector?.(selector);
60
- if (root.nodeType === ELEMENT_NODE) root.matches(selector);
61
- return true;
62
- } catch (error) {
63
- console.error(`Invalid enhancer selector "${selector}"`, error);
64
- return false;
65
- }
66
- }
67
-
68
- function updateSelectorString() {
69
- selectorString = [...records].flatMap((record) => record.selectors).join(",");
70
- }
59
+ let discovery = "";
71
60
 
72
61
  function start(record, el) {
73
- if (el?.nodeType !== ELEMENT_NODE) return;
74
62
  let active = record.instances.get(el);
75
-
76
- for (const selector of record.selectors) {
77
- if (!el.matches(selector)) continue;
78
- if (active?.has(selector)) continue;
63
+ for (const key of record.keys) {
64
+ if (active?.has(key) || !el.matches(key)) continue;
79
65
 
80
66
  let cleanup;
81
67
  try {
82
- cleanup = record.enhancers[selector](el);
68
+ cleanup = record.enhancers[key](el);
83
69
  } catch (error) {
84
- console.error(`Enhancer failed for selector "${selector}"`, error);
70
+ console.error(`Enhancer "${key}" failed`, error);
85
71
  continue;
86
72
  }
87
73
  if (!active) {
88
74
  active = new Map();
89
75
  record.instances.set(el, active);
90
76
  }
91
- active.set(selector, typeof cleanup === "function" ? cleanup : null);
77
+ active.set(key, typeof cleanup === "function" ? cleanup : null);
92
78
  }
93
79
  }
94
80
 
95
81
  function stop(record, el) {
96
- const active = record.instances.get(el);
97
- if (!active) return;
98
- for (const cleanup of active.values()) {
82
+ for (const cleanup of record.instances.get(el)?.values() ?? []) {
99
83
  try {
100
84
  cleanup?.();
101
85
  } catch (error) {
@@ -105,24 +89,13 @@ function createRegistry(root) {
105
89
  record.instances.delete(el);
106
90
  }
107
91
 
108
- function scanFor(record, node) {
109
- if (!canScan(node)) return;
110
- if (node.nodeType === ELEMENT_NODE) start(record, node);
111
- if (record.selectorString) {
112
- node.querySelectorAll?.(record.selectorString).forEach((el) => {
113
- start(record, el);
114
- });
115
- }
116
- }
117
-
118
- function scan(node) {
119
- if (!canScan(node) || !selectorString) return;
120
- if (node.nodeType === ELEMENT_NODE) {
121
- for (const record of records) start(record, node);
122
- }
123
- node.querySelectorAll?.(selectorString).forEach((el) => {
124
- for (const record of records) start(record, el);
125
- });
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);
126
99
  }
127
100
 
128
101
  function sweepDisconnected() {
@@ -133,11 +106,16 @@ function createRegistry(root) {
133
106
  }
134
107
  }
135
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
+
136
114
  const observer = new MutationObserver((mutationRecords) => {
137
115
  let hasRemoval = false;
138
116
  for (const mutationRecord of mutationRecords) {
139
117
  for (const node of mutationRecord.addedNodes) {
140
- scan(node);
118
+ scan(node, records, discovery);
141
119
  }
142
120
  if (mutationRecord.removedNodes.length > 0) {
143
121
  hasRemoval = true;
@@ -147,61 +125,52 @@ function createRegistry(root) {
147
125
  });
148
126
 
149
127
  return {
150
- add(enhancers) {
151
- const selectors = Object.keys(enhancers).filter(isValidSelector);
152
- if (!selectors.length) return null;
153
- const validEnhancers = Object.fromEntries(
154
- selectors.map((selector) => [selector, enhancers[selector]]),
155
- );
156
-
157
- const record = {
158
- disconnected: false,
159
- enhancers: validEnhancers,
160
- instances: new Map(),
161
- selectors,
162
- selectorString: selectors.join(","),
163
- };
128
+ add(spec) {
129
+ const record = { ...spec, disconnected: false, instances: new Map() };
164
130
  records.add(record);
165
131
  // Observe only once there is a record to act on; an enhance() call with
166
132
  // no valid selector must not leave an idle observer behind.
167
133
  if (records.size === 1) observer.observe(root, { childList: true, subtree: true });
168
- updateSelectorString();
169
- scanFor(record, root);
170
- return record;
171
- },
172
- refresh(record, node) {
173
- if (record.disconnected) return;
174
- scanFor(record, node);
175
- },
176
- remove(record) {
177
- if (record.disconnected) return;
178
- record.disconnected = true;
179
- for (const el of Array.from(record.instances.keys())) {
180
- stop(record, el);
181
- }
182
- records.delete(record);
183
- updateSelectorString();
184
- if (records.size === 0) {
185
- observer.disconnect();
186
- registries.delete(root);
187
- }
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
+ };
188
152
  },
189
153
  };
190
154
  }
191
155
 
192
- 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
+ }
193
164
 
194
- 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) {
195
168
  if (!ENHANCEMENT_NAME.test(name)) {
196
169
  throw new TypeError(`Invalid enhancement name: ${name}`);
197
170
  }
198
171
  return `[data-enhance~="${name}"]`;
199
172
  }
200
173
 
201
- export function hasEnhancement(el, name) {
202
- return el.matches(enhancementSelector(name));
203
- }
204
-
205
174
  /**
206
175
  * Apply a named opt-in once; presentation selectors remain application-owned.
207
176
  * @param {string} name
@@ -212,12 +181,12 @@ export function hasEnhancement(el, name) {
212
181
  export function applyEnhancement(name, selector, root) {
213
182
  if (typeof document === "undefined") return [];
214
183
 
215
- enhancementSelector(name);
184
+ const enhanced = enhancementSelector(name);
216
185
  root ??= document.documentElement;
217
186
  const elements = [...root.querySelectorAll(selector)];
218
187
 
219
188
  for (const el of elements) {
220
- if (hasEnhancement(el, name)) continue;
189
+ if (el.matches(enhanced)) continue;
221
190
  const tokens = el.getAttribute("data-enhance");
222
191
  el.setAttribute("data-enhance", tokens ? `${tokens} ${name}` : name);
223
192
  }
@@ -236,7 +205,7 @@ export function applyEnhancement(name, selector, root) {
236
205
  export function registerEnhancement(name, init, root) {
237
206
  if (typeof document === "undefined") return noopRuntime();
238
207
 
239
- enhancementSelector(name);
208
+ const selector = enhancementSelector(name);
240
209
  if (typeof init !== "function") {
241
210
  throw new TypeError("registerEnhancement() requires a function init.");
242
211
  }
@@ -251,7 +220,11 @@ export function registerEnhancement(name, init, root) {
251
220
  throw new Error(`Enhancement "${name}" is already registered on this root.`);
252
221
  }
253
222
 
254
- 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
+ });
255
228
  names.set(name, runtime);
256
229
 
257
230
  return {
@@ -264,34 +237,26 @@ export function registerEnhancement(name, init, root) {
264
237
  };
265
238
  }
266
239
 
267
- function registryFor(root) {
268
- let registry = registries.get(root);
269
- if (!registry) {
270
- registry = createRegistry(root);
271
- registries.set(root, registry);
272
- }
273
- return registry;
274
- }
275
-
276
240
  /**
277
241
  * @param {Record<string, (el: Element) => (() => void) | void>} enhancers
278
242
  * @param {Document | Element | DocumentFragment} [root]
279
243
  * @returns {{ refresh: (node: Node) => void, disconnect: () => void }}
280
244
  */
281
245
  export default function enhance(enhancers, root) {
282
- if (typeof document === "undefined") {
283
- return noopRuntime();
284
- }
246
+ if (typeof document === "undefined") return noopRuntime();
285
247
 
286
248
  root ??= document.documentElement;
287
- const registry = registryFor(root);
288
- const record = registry.add(enhancers);
289
- 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();
290
260
 
291
- return {
292
- refresh: (node) => registry.refresh(record, node),
293
- disconnect: () => {
294
- registry.remove(record);
295
- },
296
- };
261
+ return registryFor(root).add({ keys, enhancers, query: keys.join(",") });
297
262
  }
package/src/js/escape.js CHANGED
@@ -30,6 +30,7 @@ function onDocumentEscape(event) {
30
30
  if (
31
31
  event.defaultPrevented ||
32
32
  event.key !== "Escape" ||
33
+ event.isComposing ||
33
34
  event.ctrlKey ||
34
35
  event.altKey ||
35
36
  event.shiftKey ||
package/src/js/flyout.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { registerEnhancement } from "./enhance.js";
2
2
  import { focusFirstDescendant } from "./focus.js";
3
+ import { shouldIgnoreKey } from "./keys.js";
3
4
  import { connectMenu, focusFirstMenuItem, focusLastMenuItem } from "./menu.js";
4
5
  import { closeSurface, isSurfaceOpen, openSurface, retainSurface } from "./surface.js";
5
6
 
@@ -26,6 +27,13 @@ function isMenuFlyout(panel) {
26
27
  return panel.matches("menu") || panel.classList.contains("menu");
27
28
  }
28
29
 
30
+ // ARIA-wise only [role="menu"] is a menu: <menu> carries the implicit list
31
+ // role and .menu is a visual class. aria-haspopup="menu" must follow the
32
+ // role, not the tag or class.
33
+ function isAriaMenu(panel) {
34
+ return panel.getAttribute("role") === "menu";
35
+ }
36
+
29
37
  function openFlyout(panel, trigger) {
30
38
  return openSurface(panel, {
31
39
  trigger,
@@ -57,6 +65,7 @@ function onTriggerClick(e) {
57
65
 
58
66
  function onTriggerKeydown(e) {
59
67
  const trigger = e.currentTarget;
68
+ if (shouldIgnoreKey(e)) return;
60
69
  const state = triggerMap.get(trigger);
61
70
  if (!state) return;
62
71
  const panel = resolvePanel(trigger, state);
@@ -153,7 +162,7 @@ function resolvePanel(trigger, state) {
153
162
  close: (menu) => closeSurface(menu),
154
163
  });
155
164
  }
156
- if (!trigger.hasAttribute("aria-haspopup") && isMenuFlyout(panel)) {
165
+ if (!trigger.hasAttribute("aria-haspopup") && isAriaMenu(panel)) {
157
166
  trigger.setAttribute("aria-haspopup", "menu");
158
167
  }
159
168
 
@@ -5,7 +5,7 @@
5
5
  * candidate list; the primitive does not observe the subtree.
6
6
  */
7
7
 
8
- import { itemForKey } from "./keys.js";
8
+ import { itemForKey, shouldIgnoreKey } from "./keys.js";
9
9
 
10
10
  const ORIENTATIONS = new Set(["horizontal", "vertical"]);
11
11
 
@@ -82,6 +82,7 @@ export function connectFocusGroup(root, opts = {}) {
82
82
  }
83
83
 
84
84
  function onKeydown(event) {
85
+ if (shouldIgnoreKey(event)) return;
85
86
  const items = sync();
86
87
  const current = root.ownerDocument.activeElement;
87
88
  if (!items.includes(current)) return;
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";
package/src/js/keys.js CHANGED
@@ -5,6 +5,20 @@
5
5
  * centralize item lookup, edge handling, and optional wrapping.
6
6
  */
7
7
 
8
+ // Shared guard for roving-focus key handlers. Composition (IME) and
9
+ // ctrl/alt/meta-modified keys belong to the input method or the browser
10
+ // (Alt+Arrow = history navigation) and must never be claimed by a widget.
11
+ // Shift is deliberately excluded: Shift+Tab carries focus semantics that
12
+ // callers handle themselves.
13
+ export function shouldIgnoreKey(event) {
14
+ return (
15
+ event?.isComposing === true ||
16
+ event?.ctrlKey === true ||
17
+ event?.altKey === true ||
18
+ event?.metaKey === true
19
+ );
20
+ }
21
+
8
22
  export function firstItem(items) {
9
23
  return items[0] || null;
10
24
  }
package/src/js/menu.js CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { isElementVisible } from "./focus.js";
12
12
  import { connectFocusGroup } from "./focus-group.js";
13
- import { firstItem, itemForKey, lastItem } from "./keys.js";
13
+ import { firstItem, itemForKey, lastItem, shouldIgnoreKey } from "./keys.js";
14
14
 
15
15
  const MENU_ITEM_SELECTOR = ":scope > li > .menu-item";
16
16
  const MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
@@ -62,6 +62,7 @@ export function focusLastMenuItem(menu) {
62
62
  }
63
63
 
64
64
  export function onMenuKeydown(e, { close, navigate = true }) {
65
+ if (shouldIgnoreKey(e)) return;
65
66
  const menu = e.currentTarget;
66
67
  if (navigate) {
67
68
  const items = getMenuItems(menu);
@@ -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,