actual-css 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -1
- package/README.md +25 -8
- package/dist/actual-themes.min.css +1 -0
- package/dist/actual.css +1492 -931
- package/dist/actual.js +1 -3
- package/dist/actual.min.css +1 -1
- package/dist/optional.css +1048 -0
- package/dist/optional.min.css +1 -0
- package/docs/design-notes/browser-support.md +95 -6
- package/docs/design-notes/cascade-layer.md +1 -1
- package/docs/design-notes/enhancement-contract.md +101 -0
- package/docs/design-notes/forced-colors.md +105 -0
- package/docs/design-notes/naming.md +3 -1
- package/docs/design-notes/platform-alignment.md +492 -0
- package/docs/design-notes/references.md +8 -0
- package/docs/design-notes/widget-primitives.md +119 -0
- package/docs/navigation.json +135 -0
- package/docs/pages/components/accordion.md +80 -0
- package/docs/pages/components/alert.md +203 -0
- package/docs/pages/components/aura.md +35 -0
- package/docs/pages/components/avatar.md +95 -0
- package/docs/pages/components/badge.md +102 -0
- package/docs/pages/components/breadcrumb.md +19 -0
- package/docs/pages/components/button.md +205 -0
- package/docs/pages/components/card.md +183 -0
- package/docs/pages/components/chat.md +50 -0
- package/docs/pages/components/dialog.md +367 -0
- package/docs/pages/components/drawer.md +113 -0
- package/docs/pages/components/fab.md +118 -0
- package/docs/pages/components/key.md +33 -0
- package/docs/pages/components/meter.md +23 -0
- package/docs/pages/components/navbar.md +92 -0
- package/docs/pages/components/pagination.md +55 -0
- package/docs/pages/components/progress.md +29 -0
- package/docs/pages/components/skeleton.md +38 -0
- package/docs/pages/components/spinner.md +93 -0
- package/docs/pages/components/table.md +102 -0
- package/docs/pages/enhancements/flyout.md +391 -0
- package/docs/pages/enhancements/overview.md +36 -0
- package/docs/pages/enhancements/scrollspy.md +71 -0
- package/docs/pages/enhancements/status-bar.md +72 -0
- package/docs/pages/enhancements/tabs.md +80 -0
- package/docs/pages/enhancements/tooltip.md +130 -0
- package/docs/pages/examples/overview.md +41 -0
- package/docs/pages/forms/choice-cards.md +102 -0
- package/docs/pages/forms/controls.md +32 -0
- package/docs/pages/forms/detached-actions.md +59 -0
- package/docs/pages/forms/floating-field.md +88 -0
- package/docs/pages/forms/form-actions.md +56 -0
- package/docs/pages/forms/input-policy.md +91 -0
- package/docs/pages/forms/joined-controls.md +37 -0
- package/docs/pages/forms/labels.md +42 -0
- package/docs/pages/forms/otp.md +50 -0
- package/docs/pages/forms/overview.md +188 -0
- package/docs/pages/forms/password.md +40 -0
- package/docs/pages/forms/range.md +17 -0
- package/docs/pages/forms/states-and-sizes.md +321 -0
- package/docs/pages/forms/sticky-actions.md +187 -0
- package/docs/pages/forms/switch.md +31 -0
- package/docs/pages/forms/validation.md +241 -0
- package/docs/{mental-model.md → pages/foundations/philosophy.md} +33 -18
- package/docs/pages/foundations/theming.md +92 -0
- package/docs/pages/foundations/tokens.md +421 -0
- package/docs/{typography.md → pages/foundations/typography.md} +27 -9
- package/docs/pages/guides/bootstrap.md +438 -0
- package/docs/pages/guides/introduction.md +157 -0
- package/docs/pages/guides/optional-components.md +33 -0
- package/docs/pages/guides/progressive-enhancement.md +686 -0
- package/docs/pages/guides/tailwind.md +455 -0
- package/docs/pages/layout/app-shell.md +25 -0
- package/docs/pages/layout/center.md +29 -0
- package/docs/pages/layout/cluster.md +57 -0
- package/docs/pages/layout/frame.md +30 -0
- package/docs/pages/layout/grid.md +142 -0
- package/docs/pages/layout/list.md +41 -0
- package/docs/pages/layout/media.md +21 -0
- package/docs/pages/layout/overview.md +37 -0
- package/docs/pages/layout/scroll-snap.md +54 -0
- package/docs/pages/layout/sidebar-layout.md +27 -0
- package/docs/pages/layout/stack.md +18 -0
- package/docs/pages/layout/switcher.md +36 -0
- package/docs/pages/layout/topbar.md +23 -0
- package/docs/pages/patterns/actions.md +52 -0
- package/docs/pages/patterns/code-block.md +36 -0
- package/docs/pages/patterns/exclusive-accordion.md +16 -0
- package/docs/pages/patterns/hierarchical-nav.md +26 -0
- package/docs/pages/patterns/icon-input.md +25 -0
- package/docs/pages/patterns/intent-nesting.md +3 -0
- package/docs/pages/patterns/navigation-list.md +235 -0
- package/docs/pages/patterns/overview.md +58 -0
- package/docs/pages/patterns/structured-lists.md +47 -0
- package/docs/pages/utilities/accessibility.md +34 -0
- package/docs/pages/utilities/flex.md +105 -0
- package/docs/pages/utilities/links.md +18 -0
- package/docs/pages/utilities/list.md +31 -0
- package/docs/pages/utilities/overflow.md +43 -0
- package/docs/pages/utilities/overview.md +18 -0
- package/docs/pages/utilities/shape.md +25 -0
- package/docs/pages/utilities/sizing-wrapping.md +115 -0
- package/docs/pages/utilities/spacing.md +79 -0
- package/docs/pages/utilities/text.md +43 -0
- package/llms.txt +10 -4
- package/package.json +30 -16
- package/src/css/actual.css +12 -1
- package/src/css/base.css +3 -9
- package/src/css/components/accordion.css +18 -30
- package/src/css/components/actions.css +4 -1
- package/src/css/components/alert.css +162 -49
- package/src/css/components/avatar.css +16 -10
- package/src/css/components/badge.css +82 -39
- package/src/css/components/breadcrumb.css +7 -15
- package/src/css/components/busy.css +7 -0
- package/src/css/components/button.css +82 -38
- package/src/css/components/card.css +17 -37
- package/src/css/components/drawer.css +30 -13
- package/src/css/components/flyout.css +39 -110
- package/src/css/components/index.css +1 -0
- package/src/css/components/join.css +14 -3
- package/src/css/components/key.css +2 -0
- package/src/css/components/menu.css +125 -0
- package/src/css/components/meter.css +0 -23
- package/src/css/components/modal.css +162 -7
- package/src/css/components/navbar.css +17 -18
- package/src/css/components/overline.css +0 -7
- package/src/css/components/pagination.css +1 -1
- package/src/css/components/progress.css +8 -26
- package/src/css/components/scrollspy.css +2 -2
- package/src/css/components/skeleton.css +7 -0
- package/src/css/components/spinner.css +10 -11
- package/src/css/components/status-bar.css +18 -28
- package/src/css/components/surface.css +2 -8
- package/src/css/components/tab.css +20 -48
- package/src/css/components/table.css +7 -5
- package/src/css/components/tooltip.css +14 -7
- package/src/css/focus.css +16 -3
- package/src/css/forms/choice-card.css +13 -15
- package/src/css/forms/choice.css +21 -28
- package/src/css/forms/control.css +16 -26
- package/src/css/forms/custom-select.css +9 -11
- package/src/css/forms/form-actions.css +14 -3
- package/src/css/forms/form.css +7 -4
- package/src/css/forms/index.css +5 -4
- package/src/css/forms/input-icon.css +30 -12
- package/src/css/forms/native.css +48 -33
- package/src/css/forms/range.css +103 -0
- package/src/css/forms/select.css +8 -8
- package/src/css/forms/switch.css +14 -13
- package/src/css/forms/validation.css +32 -5
- package/src/css/grid.css +19 -18
- package/src/css/intents.css +2 -2
- package/src/css/layout.css +29 -1
- package/src/css/optional/aura.css +93 -0
- package/src/css/optional/chat.css +105 -0
- package/src/css/optional/fab.css +104 -0
- package/src/css/optional/floating-field.css +87 -0
- package/src/css/optional/index.css +5 -0
- package/src/css/optional/layout-extra.css +7 -4
- package/src/css/optional/otp.css +115 -0
- package/src/css/optional/scroll-snap.css +6 -0
- package/src/css/optional/scroller.css +19 -7
- package/src/css/optional/typography-fluid.css +5 -0
- package/src/css/optional/utilities-extra.css +17 -2
- package/src/css/prose.css +18 -8
- package/src/css/reset.css +0 -10
- package/src/css/theme.css +29 -13
- package/src/css/themes/cyberpunk.css +1 -1
- package/src/css/themes/dim.css +3 -3
- package/src/css/themes/edge.css +11 -37
- package/src/css/themes/index.css +3 -3
- package/src/css/themes/{dark.css → indigo.css} +5 -4
- package/src/css/themes/neon.css +5 -5
- package/src/css/themes/square.css +0 -3
- package/src/css/tokens.css +81 -21
- package/src/css/utilities.css +8 -10
- package/src/css/variants.css +37 -65
- package/src/js/command.js +1 -3
- package/src/js/context-menu.js +72 -53
- package/src/js/dialog.js +68 -79
- package/src/js/dismiss.js +15 -0
- package/src/js/enhance.js +76 -19
- package/src/js/enhancement-loader.js +301 -0
- package/src/js/events.js +3 -0
- package/src/js/filter.js +3 -3
- package/src/js/floating.js +101 -131
- package/src/js/flyout.js +106 -96
- package/src/js/focus-group.js +116 -0
- package/src/js/focus.js +6 -1
- package/src/js/index.js +4 -2
- package/src/js/input.js +1 -1
- package/src/js/keys.js +27 -0
- package/src/js/mask.js +7 -6
- package/src/js/menu.js +89 -35
- package/src/js/parse-config.js +47 -0
- package/src/js/password.js +9 -1
- package/src/js/scrollspy.js +133 -62
- package/src/js/selectors.js +14 -31
- package/src/js/status.js +16 -14
- package/src/js/surface.js +219 -125
- package/src/js/tab.js +46 -66
- package/src/js/tooltip.js +53 -31
- package/src/js/validation.js +45 -51
- package/dist/actual.js.map +0 -31
- package/docs/README.md +0 -26
- package/docs/components.md +0 -860
- package/docs/design-notes/previous-work.md +0 -5
- package/docs/forms.md +0 -1265
- package/docs/javascript.md +0 -426
- package/docs/layout.md +0 -480
- package/docs/patterns.md +0 -415
- package/docs/reserved.md +0 -7
- package/docs/tokens.md +0 -455
- package/docs/ui.md +0 -937
- package/docs/utilities.md +0 -384
- package/src/css/dialog-fallback.css +0 -56
- package/src/js/dialog-fallback.js +0 -163
package/src/js/enhance.js
CHANGED
|
@@ -13,24 +13,40 @@
|
|
|
13
13
|
* attributes are setup-time contracts, not live enable/disable switches.
|
|
14
14
|
*
|
|
15
15
|
* enhancers: { [selector]: (el) => cleanup | void }
|
|
16
|
-
* Returns: { refresh,
|
|
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.
|
|
25
|
+
*
|
|
26
|
+
* See docs/design-notes/enhancement-contract.md.
|
|
17
27
|
*/
|
|
18
28
|
|
|
19
29
|
const registries = new WeakMap();
|
|
30
|
+
const ownedNames = new WeakMap();
|
|
31
|
+
|
|
32
|
+
// Node type constants as literals, not the realm's Node global, so nodes from
|
|
33
|
+
// another document/window compare correctly.
|
|
34
|
+
const ELEMENT_NODE = 1;
|
|
35
|
+
const DOCUMENT_NODE = 9;
|
|
36
|
+
const DOCUMENT_FRAGMENT_NODE = 11;
|
|
20
37
|
|
|
21
38
|
function noopRuntime() {
|
|
22
39
|
return {
|
|
23
40
|
refresh() {},
|
|
24
|
-
forget() {},
|
|
25
41
|
disconnect() {},
|
|
26
42
|
};
|
|
27
43
|
}
|
|
28
44
|
|
|
29
45
|
function canScan(node) {
|
|
30
46
|
return (
|
|
31
|
-
node?.nodeType ===
|
|
32
|
-
node?.nodeType ===
|
|
33
|
-
node?.nodeType ===
|
|
47
|
+
node?.nodeType === ELEMENT_NODE ||
|
|
48
|
+
node?.nodeType === DOCUMENT_NODE ||
|
|
49
|
+
node?.nodeType === DOCUMENT_FRAGMENT_NODE
|
|
34
50
|
);
|
|
35
51
|
}
|
|
36
52
|
|
|
@@ -41,7 +57,7 @@ function createRegistry(root) {
|
|
|
41
57
|
function isValidSelector(selector) {
|
|
42
58
|
try {
|
|
43
59
|
root.querySelector?.(selector);
|
|
44
|
-
if (root
|
|
60
|
+
if (root.nodeType === ELEMENT_NODE) root.matches(selector);
|
|
45
61
|
return true;
|
|
46
62
|
} catch (error) {
|
|
47
63
|
console.error(`Invalid enhancer selector "${selector}"`, error);
|
|
@@ -54,7 +70,7 @@ function createRegistry(root) {
|
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
function start(record, el) {
|
|
57
|
-
if (
|
|
73
|
+
if (el?.nodeType !== ELEMENT_NODE) return;
|
|
58
74
|
let active = record.instances.get(el);
|
|
59
75
|
|
|
60
76
|
for (const selector of record.selectors) {
|
|
@@ -80,14 +96,18 @@ function createRegistry(root) {
|
|
|
80
96
|
const active = record.instances.get(el);
|
|
81
97
|
if (!active) return;
|
|
82
98
|
for (const cleanup of active.values()) {
|
|
83
|
-
|
|
99
|
+
try {
|
|
100
|
+
cleanup?.();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error("Enhancer cleanup failed", error);
|
|
103
|
+
}
|
|
84
104
|
}
|
|
85
105
|
record.instances.delete(el);
|
|
86
106
|
}
|
|
87
107
|
|
|
88
108
|
function scanFor(record, node) {
|
|
89
109
|
if (!canScan(node)) return;
|
|
90
|
-
if (node
|
|
110
|
+
if (node.nodeType === ELEMENT_NODE) start(record, node);
|
|
91
111
|
if (record.selectorString) {
|
|
92
112
|
node.querySelectorAll?.(record.selectorString).forEach((el) => {
|
|
93
113
|
start(record, el);
|
|
@@ -97,7 +117,7 @@ function createRegistry(root) {
|
|
|
97
117
|
|
|
98
118
|
function scan(node) {
|
|
99
119
|
if (!canScan(node) || !selectorString) return;
|
|
100
|
-
if (node
|
|
120
|
+
if (node.nodeType === ELEMENT_NODE) {
|
|
101
121
|
for (const record of records) start(record, node);
|
|
102
122
|
}
|
|
103
123
|
node.querySelectorAll?.(selectorString).forEach((el) => {
|
|
@@ -108,7 +128,7 @@ function createRegistry(root) {
|
|
|
108
128
|
function sweepDisconnected() {
|
|
109
129
|
for (const record of records) {
|
|
110
130
|
for (const el of Array.from(record.instances.keys())) {
|
|
111
|
-
if (!el.isConnected) stop(record, el);
|
|
131
|
+
if (!el.isConnected || !root.contains(el)) stop(record, el);
|
|
112
132
|
}
|
|
113
133
|
}
|
|
114
134
|
}
|
|
@@ -152,10 +172,6 @@ function createRegistry(root) {
|
|
|
152
172
|
if (record.disconnected) return;
|
|
153
173
|
scanFor(record, node);
|
|
154
174
|
},
|
|
155
|
-
forget(record, el) {
|
|
156
|
-
if (record.disconnected) return;
|
|
157
|
-
record.instances.delete(el);
|
|
158
|
-
},
|
|
159
175
|
remove(record) {
|
|
160
176
|
if (record.disconnected) return;
|
|
161
177
|
record.disconnected = true;
|
|
@@ -172,6 +188,50 @@ function createRegistry(root) {
|
|
|
172
188
|
};
|
|
173
189
|
}
|
|
174
190
|
|
|
191
|
+
const ENHANCEMENT_NAME = /^[a-z][a-z0-9-]*$/;
|
|
192
|
+
|
|
193
|
+
export function enhancementSelector(name) {
|
|
194
|
+
if (!ENHANCEMENT_NAME.test(name)) {
|
|
195
|
+
throw new TypeError(`Invalid enhancement name: ${name}`);
|
|
196
|
+
}
|
|
197
|
+
return `[data-enhance~="${name}"]`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function hasEnhancement(el, name) {
|
|
201
|
+
return el.matches(enhancementSelector(name));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function registerEnhancement(name, init, root) {
|
|
205
|
+
if (typeof document === "undefined") return noopRuntime();
|
|
206
|
+
|
|
207
|
+
enhancementSelector(name);
|
|
208
|
+
if (typeof init !== "function") {
|
|
209
|
+
throw new TypeError("registerEnhancement() requires a function init.");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
root ??= document.documentElement;
|
|
213
|
+
let names = ownedNames.get(root);
|
|
214
|
+
if (!names) {
|
|
215
|
+
names = new Map();
|
|
216
|
+
ownedNames.set(root, names);
|
|
217
|
+
}
|
|
218
|
+
if (names.has(name)) {
|
|
219
|
+
throw new Error(`Enhancement "${name}" is already registered on this root.`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const runtime = enhance({ [enhancementSelector(name)]: init }, root);
|
|
223
|
+
names.set(name, runtime);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
refresh: (node) => runtime.refresh(node),
|
|
227
|
+
disconnect: () => {
|
|
228
|
+
runtime.disconnect();
|
|
229
|
+
if (names.get(name) === runtime) names.delete(name);
|
|
230
|
+
if (names.size === 0) ownedNames.delete(root);
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
175
235
|
function registryFor(root) {
|
|
176
236
|
let registry = registries.get(root);
|
|
177
237
|
if (!registry) {
|
|
@@ -184,7 +244,7 @@ function registryFor(root) {
|
|
|
184
244
|
/**
|
|
185
245
|
* @param {Record<string, (el: Element) => (() => void) | void>} enhancers
|
|
186
246
|
* @param {Document | Element | DocumentFragment} [root]
|
|
187
|
-
* @returns {{ refresh: (node: Node) => void,
|
|
247
|
+
* @returns {{ refresh: (node: Node) => void, disconnect: () => void }}
|
|
188
248
|
*/
|
|
189
249
|
export default function enhance(enhancers, root) {
|
|
190
250
|
if (typeof document === "undefined") {
|
|
@@ -198,9 +258,6 @@ export default function enhance(enhancers, root) {
|
|
|
198
258
|
|
|
199
259
|
return {
|
|
200
260
|
refresh: (node) => registry.refresh(record, node),
|
|
201
|
-
forget: (el) => {
|
|
202
|
-
registry.forget(record, el);
|
|
203
|
-
},
|
|
204
261
|
disconnect: () => {
|
|
205
262
|
registry.remove(record);
|
|
206
263
|
},
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* enhancement-loader.js — Server-declared enhancement manifest loader.
|
|
3
|
+
*
|
|
4
|
+
* Reads <script type="application/json" data-enhance-modules"> blocks or
|
|
5
|
+
* the Enhance-Modules HTTP header, dynamically imports the declared ES
|
|
6
|
+
* module entrypoints, and registers them with the Actual lifecycle engine.
|
|
7
|
+
*
|
|
8
|
+
* Manifests declare document-wide modules: dynamically loaded enhancements
|
|
9
|
+
* always register on the default document root. A scope passed to
|
|
10
|
+
* loadEnhancements() only limits where manifest blocks are looked up.
|
|
11
|
+
*
|
|
12
|
+
* One map keyed by name reserves the slot immediately, so concurrent calls
|
|
13
|
+
* for the same name share one promise. The browser's native module cache
|
|
14
|
+
* handles URL dedup. Script/style helpers fill the gap for non-ESM assets.
|
|
15
|
+
*
|
|
16
|
+
* Exports prefixed with `__` are test hooks and are not part of the
|
|
17
|
+
* supported public API or SemVer contract.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import enhance, { registerEnhancement } from "./enhance.js";
|
|
21
|
+
import { parseConfig } from "./parse-config.js";
|
|
22
|
+
|
|
23
|
+
const MANIFEST_SELECTOR = 'script[type="application/json"][data-enhance-modules]';
|
|
24
|
+
|
|
25
|
+
const enhancements = new Map();
|
|
26
|
+
|
|
27
|
+
const scriptLoads = new Map();
|
|
28
|
+
const styleLoads = new Map();
|
|
29
|
+
|
|
30
|
+
let moduleImporter = (url) => import(url);
|
|
31
|
+
|
|
32
|
+
export function loadScript(url) {
|
|
33
|
+
const absoluteUrl = new URL(url, document.baseURI).href;
|
|
34
|
+
|
|
35
|
+
if (!scriptLoads.has(absoluteUrl)) {
|
|
36
|
+
scriptLoads.set(
|
|
37
|
+
absoluteUrl,
|
|
38
|
+
new Promise((resolve, reject) => {
|
|
39
|
+
const script = document.createElement("script");
|
|
40
|
+
script.src = absoluteUrl;
|
|
41
|
+
script.onload = resolve;
|
|
42
|
+
script.onerror = () => {
|
|
43
|
+
scriptLoads.delete(absoluteUrl);
|
|
44
|
+
script.remove();
|
|
45
|
+
reject(new Error(`Unable to load script: ${absoluteUrl}`));
|
|
46
|
+
};
|
|
47
|
+
document.head.append(script);
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return scriptLoads.get(absoluteUrl);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadStyle(url) {
|
|
56
|
+
const absoluteUrl = new URL(url, document.baseURI).href;
|
|
57
|
+
|
|
58
|
+
if (!styleLoads.has(absoluteUrl)) {
|
|
59
|
+
styleLoads.set(
|
|
60
|
+
absoluteUrl,
|
|
61
|
+
new Promise((resolve, reject) => {
|
|
62
|
+
const link = document.createElement("link");
|
|
63
|
+
link.rel = "stylesheet";
|
|
64
|
+
link.href = absoluteUrl;
|
|
65
|
+
link.onload = resolve;
|
|
66
|
+
link.onerror = () => {
|
|
67
|
+
styleLoads.delete(absoluteUrl);
|
|
68
|
+
link.remove();
|
|
69
|
+
reject(new Error(`Unable to load style: ${absoluteUrl}`));
|
|
70
|
+
};
|
|
71
|
+
document.head.append(link);
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return styleLoads.get(absoluteUrl);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function makeInit(name, connect) {
|
|
80
|
+
return (element) => {
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const source = element.getAttribute("data-enhance-config");
|
|
83
|
+
const config = parseConfig(source ?? "");
|
|
84
|
+
|
|
85
|
+
const emit = (type, detail, options = {}) =>
|
|
86
|
+
element.dispatchEvent(
|
|
87
|
+
new CustomEvent(`actual:${name}:${type}`, {
|
|
88
|
+
bubbles: true,
|
|
89
|
+
detail,
|
|
90
|
+
...options,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
let cleanup;
|
|
95
|
+
try {
|
|
96
|
+
cleanup = connect(element, {
|
|
97
|
+
config,
|
|
98
|
+
signal: controller.signal,
|
|
99
|
+
emit,
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
// A throwing connect() must not leak the local controller's listeners.
|
|
103
|
+
controller.abort();
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (cleanup?.then) {
|
|
108
|
+
controller.abort();
|
|
109
|
+
throw new TypeError(
|
|
110
|
+
`Enhancement "${name}" returned a Promise. connect() must be synchronous; use prepare() for asynchronous setup.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (cleanup !== undefined && typeof cleanup !== "function") {
|
|
115
|
+
controller.abort();
|
|
116
|
+
throw new TypeError(`Enhancement "${name}" must return a cleanup function or undefined.`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
controller.abort();
|
|
121
|
+
cleanup?.();
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function assertManifestUrl(name, url) {
|
|
127
|
+
if (typeof url !== "string" || url.trim() === "") {
|
|
128
|
+
throw new TypeError(`Enhancement "${name}" must declare a non-empty module URL.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function loadEnhancement(name, url) {
|
|
133
|
+
assertManifestUrl(name, url);
|
|
134
|
+
const absoluteUrl = new URL(url, document.baseURI).href;
|
|
135
|
+
const existing = enhancements.get(name);
|
|
136
|
+
|
|
137
|
+
if (existing) {
|
|
138
|
+
if (existing.url !== absoluteUrl) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Enhancement "${name}" is already declared from ${existing.url} and cannot be replaced by ${absoluteUrl}.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return existing.promise.then(() => ({ name, status: "skipped" }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const promise = moduleImporter(absoluteUrl)
|
|
147
|
+
.then(async (mod) => {
|
|
148
|
+
if (typeof mod.default !== "function") {
|
|
149
|
+
throw new TypeError(`Enhancement "${name}" must export a default function.`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (mod.prepare !== undefined) {
|
|
153
|
+
if (typeof mod.prepare !== "function") {
|
|
154
|
+
throw new TypeError(`Enhancement "${name}" exports an invalid prepare().`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await mod.prepare({
|
|
158
|
+
resolve: (path) => new URL(path, absoluteUrl).href,
|
|
159
|
+
loadScript,
|
|
160
|
+
loadStyle,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
registerEnhancement(name, makeInit(name, mod.default));
|
|
165
|
+
|
|
166
|
+
return { name, status: "loaded" };
|
|
167
|
+
})
|
|
168
|
+
.catch((error) => {
|
|
169
|
+
if (enhancements.get(name)?.promise === promise) {
|
|
170
|
+
enhancements.delete(name);
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
enhancements.set(name, { url: absoluteUrl, promise });
|
|
176
|
+
|
|
177
|
+
return promise;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function loadManifest(manifest) {
|
|
181
|
+
const entries = Object.entries(manifest);
|
|
182
|
+
const results = await Promise.allSettled(
|
|
183
|
+
entries.map(([name, url]) => loadEnhancement(name, url)),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const output = { names: [], failed: [], skipped: [] };
|
|
187
|
+
|
|
188
|
+
results.forEach((result, index) => {
|
|
189
|
+
const name = entries[index][0];
|
|
190
|
+
|
|
191
|
+
if (result.status === "rejected") {
|
|
192
|
+
output.failed.push({ name, error: result.reason });
|
|
193
|
+
} else if (result.value.status === "skipped") {
|
|
194
|
+
output.skipped.push(name);
|
|
195
|
+
} else {
|
|
196
|
+
output.names.push(name);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return output;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function parseManifestEntries(text) {
|
|
204
|
+
const data = JSON.parse(text);
|
|
205
|
+
|
|
206
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
207
|
+
throw new TypeError("Manifest must be a JSON object.");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return Object.entries(data);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Load every enhancement declared in the manifest blocks under `scope`.
|
|
215
|
+
*
|
|
216
|
+
* @param {Document | Element | DocumentFragment} [scope] Where to look for
|
|
217
|
+
* manifest blocks. It only limits discovery; loaded enhancements register on
|
|
218
|
+
* the default document root.
|
|
219
|
+
* @returns {Promise<{ names: string[], failed: { name: string, error: Error }[], skipped: string[] }>}
|
|
220
|
+
*/
|
|
221
|
+
export async function loadEnhancements(scope = document) {
|
|
222
|
+
const blocks = scope.querySelectorAll(MANIFEST_SELECTOR);
|
|
223
|
+
|
|
224
|
+
const manifest = new Map();
|
|
225
|
+
|
|
226
|
+
for (const block of blocks) {
|
|
227
|
+
const entries = parseManifestEntries(block.textContent);
|
|
228
|
+
|
|
229
|
+
for (const [name, rawUrl] of entries) {
|
|
230
|
+
assertManifestUrl(name, rawUrl);
|
|
231
|
+
const url = new URL(rawUrl, document.baseURI).href;
|
|
232
|
+
const existing = manifest.get(name);
|
|
233
|
+
|
|
234
|
+
if (existing && existing !== url) {
|
|
235
|
+
throw new Error(`Enhancement "${name}" is declared from both ${existing} and ${url}.`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
manifest.set(name, url);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const result = await loadManifest(Object.fromEntries(manifest));
|
|
243
|
+
|
|
244
|
+
if (result.failed.length === 0) {
|
|
245
|
+
for (const block of blocks) block.remove();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function loadManifestBlock(block) {
|
|
252
|
+
const entries = parseManifestEntries(block.textContent);
|
|
253
|
+
const result = await loadManifest(Object.fromEntries(entries));
|
|
254
|
+
|
|
255
|
+
for (const failure of result.failed) {
|
|
256
|
+
console.error(`Enhancement "${failure.name}" failed to load:`, failure.error);
|
|
257
|
+
}
|
|
258
|
+
if (result.failed.length === 0) block.remove();
|
|
259
|
+
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function watchEnhancementManifests() {
|
|
264
|
+
return enhance({
|
|
265
|
+
[MANIFEST_SELECTOR]: (block) => {
|
|
266
|
+
loadManifestBlock(block).catch((error) => {
|
|
267
|
+
console.error("Failed to parse enhancement manifest:", error);
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function loadResponse(response) {
|
|
274
|
+
const header = response.headers.get("Enhance-Modules");
|
|
275
|
+
|
|
276
|
+
if (!header) return { names: [], failed: [], skipped: [] };
|
|
277
|
+
|
|
278
|
+
let manifest;
|
|
279
|
+
try {
|
|
280
|
+
manifest = JSON.parse(header);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
throw new SyntaxError(`Invalid JSON in Enhance-Modules header: ${error.message}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
|
|
286
|
+
throw new TypeError("Enhance-Modules header must be a JSON object.");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return loadManifest(manifest);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function __setModuleImporter(fn) {
|
|
293
|
+
moduleImporter = fn;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function __reset() {
|
|
297
|
+
enhancements.clear();
|
|
298
|
+
scriptLoads.clear();
|
|
299
|
+
styleLoads.clear();
|
|
300
|
+
moduleImporter = (url) => import(url);
|
|
301
|
+
}
|
package/src/js/events.js
CHANGED
|
@@ -19,4 +19,7 @@ export const EVENTS = {
|
|
|
19
19
|
// Cancelable: dispatched by dialog.js before handling native cancel close.
|
|
20
20
|
// detail: { dialog, sourceEvent }
|
|
21
21
|
dialogCancel: `${ACTUAL_EVENT_PREFIX}:dialog-cancel`,
|
|
22
|
+
// Dispatched after a command hides its target.
|
|
23
|
+
// detail: { trigger }
|
|
24
|
+
dismiss: `${ACTUAL_EVENT_PREFIX}:dismiss`,
|
|
22
25
|
};
|
package/src/js/filter.js
CHANGED
|
@@ -60,12 +60,12 @@ function normalizeText(value) {
|
|
|
60
60
|
function filterSlug(value, event) {
|
|
61
61
|
const keepTrailingSeparator = event?.inputType === "insertText" && /[^\p{L}\p{N}]$/u.test(value);
|
|
62
62
|
const slug = normalizeText(value)
|
|
63
|
-
.
|
|
63
|
+
.toLowerCase()
|
|
64
64
|
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
65
65
|
.replace(/-{2,}/g, "-")
|
|
66
|
-
.replace(
|
|
66
|
+
.replace(/^-+/, "");
|
|
67
67
|
|
|
68
|
-
return keepTrailingSeparator ? slug : slug.replace(
|
|
68
|
+
return keepTrailingSeparator ? slug : slug.replace(/-+$/, "");
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function filterValue(value, modes, event) {
|