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.
- package/CHANGELOG.md +169 -1
- package/README.md +65 -44
- package/components.json +35 -14
- package/dist/actual.full.css +877 -583
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/docs/design-notes/app-ui.md +1 -1
- package/docs/design-notes/column-layout.md +103 -520
- package/docs/design-notes/enhancement-contract.md +16 -7
- package/docs/design-notes/forced-colors.md +12 -1
- package/docs/design-notes/naming.md +1 -1
- package/docs/design-notes/platform-alignment.md +8 -4
- package/docs/design-notes/rich-text.md +61 -0
- package/docs/design-notes/steps.md +2 -2
- package/docs/design-notes/surface-reparenting.md +8 -9
- package/docs/navigation.json +4 -0
- package/docs/pages/components/accordion.md +55 -1
- package/docs/pages/components/alert.md +9 -15
- package/docs/pages/components/aura.md +12 -13
- package/docs/pages/components/badge.md +16 -7
- package/docs/pages/components/bleed.md +42 -0
- package/docs/pages/components/button.md +30 -4
- package/docs/pages/components/card.md +21 -86
- package/docs/pages/components/chat.md +5 -0
- package/docs/pages/components/close.md +48 -0
- package/docs/pages/components/data-list.md +26 -1
- package/docs/pages/components/dialog.md +21 -18
- package/docs/pages/components/drawer.md +9 -9
- package/docs/pages/components/navbar.md +5 -5
- package/docs/pages/components/pagination.md +15 -15
- package/docs/pages/components/shimmer.md +57 -0
- package/docs/pages/components/skeleton.md +2 -1
- package/docs/pages/components/spinner.md +14 -9
- package/docs/pages/components/table.md +13 -0
- package/docs/pages/enhancements/flyout.md +2 -0
- package/docs/pages/enhancements/overview.md +5 -0
- package/docs/pages/enhancements/tabs.md +4 -0
- package/docs/pages/enhancements/tooltip.md +3 -1
- package/docs/pages/examples/overview.md +2 -1
- package/docs/pages/forms/choice-cards.md +116 -4
- package/docs/pages/forms/combobox.md +39 -12
- package/docs/pages/forms/joined-controls.md +2 -5
- package/docs/pages/forms/overview.md +10 -1
- package/docs/pages/forms/range.md +110 -5
- package/docs/pages/forms/rich-text.md +110 -0
- package/docs/pages/forms/validation.md +5 -1
- package/docs/pages/foundations/theming.md +110 -18
- package/docs/pages/foundations/tokens.md +41 -13
- package/docs/pages/guides/bootstrap.md +2 -1
- package/docs/pages/guides/introduction.md +15 -23
- package/docs/pages/guides/modular-import.md +54 -7
- package/docs/pages/guides/progressive-enhancement.md +19 -17
- package/docs/pages/guides/tailwind.md +21 -7
- package/docs/pages/layout/column-layout.md +92 -54
- package/docs/pages/layout/media.md +1 -1
- package/docs/pages/layout/overview.md +1 -1
- package/docs/pages/utilities/overview.md +1 -1
- package/docs/pages/utilities/sizing-wrapping.md +5 -3
- package/llms.txt +25 -4
- package/package.json +10 -6
- package/reserved-classes.json +6 -30
- package/skills/actual-css/PROJECT.md.example +65 -0
- package/skills/actual-css/README.md +64 -0
- package/skills/actual-css/SKILL.md +234 -0
- package/skills/actual-css/references/accessibility.md +44 -0
- package/skills/actual-css/references/api-and-vocabulary.md +94 -0
- package/skills/actual-css/references/enhancements.md +59 -0
- package/skills/actual-css/references/forms.md +49 -0
- package/skills/actual-css/references/layout-and-theming.md +82 -0
- package/skills/actual-css/references/review-checklist.md +51 -0
- package/skills/actual-css/scripts/inspect-actual-css.mjs +171 -0
- package/src/css/components/accordion.css +44 -7
- package/src/css/components/alert.css +23 -43
- package/src/css/components/app-nav.css +9 -0
- package/src/css/components/avatar.css +1 -1
- package/src/css/components/badge.css +11 -39
- package/src/css/components/bleed.css +79 -0
- package/src/css/components/button.css +6 -17
- package/src/css/components/card.css +7 -40
- package/src/css/components/close.css +91 -0
- package/src/css/components/data-list.css +18 -0
- package/src/css/components/drawer.css +15 -56
- package/src/css/components/fab.css +10 -0
- package/src/css/components/flyout.css +5 -1
- package/src/css/components/index.css +2 -0
- package/src/css/components/join.css +36 -20
- package/src/css/components/menu.css +12 -0
- package/src/css/components/meter.css +1 -1
- package/src/css/components/modal.css +10 -57
- package/src/css/components/navbar.css +27 -0
- package/src/css/components/pagination.css +3 -2
- package/src/css/components/rating.css +1 -1
- package/src/css/components/status-bar.css +2 -2
- package/src/css/components/steps.css +3 -3
- package/src/css/components/tab.css +3 -3
- package/src/css/components/table.css +6 -0
- package/src/css/components/tooltip.css +1 -1
- package/src/css/core/focus.css +19 -3
- package/src/css/core/theme.css +33 -43
- package/src/css/core/tokens.css +9 -5
- package/src/css/core/variants.css +5 -34
- package/src/css/effects/aura.css +51 -33
- package/src/css/effects/index.css +1 -0
- package/src/css/effects/shimmer.css +57 -0
- package/src/css/forms/choice-card.css +142 -24
- package/src/css/forms/choice.css +2 -3
- package/src/css/forms/control.css +17 -10
- package/src/css/forms/custom-select.css +4 -4
- package/src/css/forms/native.css +4 -4
- package/src/css/forms/otp.css +9 -1
- package/src/css/forms/range.css +72 -14
- package/src/css/forms/switch.css +2 -3
- package/src/css/layout/app-layout.css +1 -1
- package/src/css/layout/column-layout.css +26 -111
- package/src/css/utilities/extra.css +7 -6
- package/src/js/command.js +61 -32
- package/src/js/dialog.js +103 -47
- package/src/js/dismiss.js +1 -2
- package/src/js/enhance.js +91 -126
- package/src/js/escape.js +1 -0
- package/src/js/flyout.js +10 -1
- package/src/js/focus-group.js +2 -1
- package/src/js/full.js +1 -0
- package/src/js/keys.js +14 -0
- package/src/js/menu.js +2 -1
- package/src/js/password.js +1 -8
- package/src/js/range.js +151 -0
- package/src/js/scrollspy.js +4 -5
- package/src/js/status.js +21 -10
- package/src/js/surface.js +12 -8
- package/src/js/tab.js +2 -1
- package/src/js/tooltip.js +29 -2
- package/src/js/validation.js +8 -15
- package/dist/actual.css +0 -876
- package/dist/actual.js +0 -1
- 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
|
|
5
|
-
*
|
|
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.
|
|
12
|
-
* adding a behavior attribute to an
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
-
|
|
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[
|
|
68
|
+
cleanup = record.enhancers[key](el);
|
|
83
69
|
} catch (error) {
|
|
84
|
-
console.error(`Enhancer
|
|
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(
|
|
77
|
+
active.set(key, typeof cleanup === "function" ? cleanup : null);
|
|
92
78
|
}
|
|
93
79
|
}
|
|
94
80
|
|
|
95
81
|
function stop(record, el) {
|
|
96
|
-
const
|
|
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
|
|
109
|
-
if (!canScan(node)) return;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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(
|
|
151
|
-
const
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
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
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") &&
|
|
165
|
+
if (!trigger.hasAttribute("aria-haspopup") && isAriaMenu(panel)) {
|
|
157
166
|
trigger.setAttribute("aria-haspopup", "menu");
|
|
158
167
|
}
|
|
159
168
|
|
package/src/js/focus-group.js
CHANGED
|
@@ -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
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);
|
package/src/js/password.js
CHANGED
|
@@ -74,14 +74,7 @@ function watchSubmitRoot(input) {
|
|
|
74
74
|
|
|
75
75
|
registerCommands(PASSWORD_COMMANDS, {
|
|
76
76
|
resolve: resolvePasswordInput,
|
|
77
|
-
|
|
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);
|
package/src/js/range.js
ADDED
|
@@ -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
|
+
);
|
package/src/js/scrollspy.js
CHANGED
|
@@ -154,11 +154,10 @@ function setupNav(nav) {
|
|
|
154
154
|
measure();
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
// The scroll root
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
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,
|