@pie-players/pie-players-shared 1.0.0-beta.1 → 1.0.0-beta.3
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/dist/instrumentation/providers/CompositeInstrumentationProvider.js +2 -1
- package/dist/loaders/element-package-spec.d.ts +16 -0
- package/dist/loaders/element-package-spec.js +30 -0
- package/dist/loaders/esm-adapter.d.ts +2 -1
- package/dist/loaders/esm-adapter.js +25 -4
- package/dist/loaders/index.d.ts +1 -0
- package/dist/loaders/index.js +1 -0
- package/dist/loaders/preloaded-registration.d.ts +18 -6
- package/dist/loaders/preloaded-registration.js +44 -9
- package/dist/promise/index.d.ts +10 -0
- package/dist/promise/index.js +16 -0
- package/dist/security/overwide-regions.d.ts +20 -0
- package/dist/security/overwide-regions.js +65 -0
- package/dist/security/wrap-overwide-images.d.ts +6 -2
- package/dist/security/wrap-overwide-images.js +5 -2
- package/dist/security/wrap-overwide-tables.d.ts +6 -2
- package/dist/security/wrap-overwide-tables.js +5 -2
- package/dist/security/wrap-overwide.d.ts +13 -0
- package/dist/security/wrap-overwide.js +68 -3
- package/package.json +7 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { settleAll } from "../../promise/index.js";
|
|
1
2
|
export class CompositeInstrumentationProvider {
|
|
2
3
|
providerId = "composite";
|
|
3
4
|
providerName = "Composite Provider";
|
|
@@ -7,7 +8,7 @@ export class CompositeInstrumentationProvider {
|
|
|
7
8
|
this.providers = providers.filter(Boolean);
|
|
8
9
|
}
|
|
9
10
|
async initialize(config) {
|
|
10
|
-
const results = await
|
|
11
|
+
const results = await settleAll(this.providers.map((provider) => provider.initialize(config)));
|
|
11
12
|
this.initialized = true;
|
|
12
13
|
if (config?.debug) {
|
|
13
14
|
for (const [index, result] of results.entries()) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape check for the package specs an item config names in `elements`.
|
|
3
|
+
*
|
|
4
|
+
* Loaders that build CDN URLs from a spec run it through
|
|
5
|
+
* `isScopedPackageSpec` first: item configs are authored content, and a
|
|
6
|
+
* version such as `1.0.0/../../../gh/owner/repo@main` would otherwise
|
|
7
|
+
* resolve to a path outside the package.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A scoped npm package name with an optional semver version or dist-tag, as
|
|
11
|
+
* item configs store it: `@pie-element/rubric`, `@pie-element/rubric@latest`,
|
|
12
|
+
* `@pie-element/rubric@8.0.0-hotfix.2`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isScopedPackageSpec(spec: unknown): spec is string;
|
|
15
|
+
/** Why a loader refused `spec`, for its failure reason. */
|
|
16
|
+
export declare function invalidPackageSpecMessage(spec: unknown): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape check for the package specs an item config names in `elements`.
|
|
3
|
+
*
|
|
4
|
+
* Loaders that build CDN URLs from a spec run it through
|
|
5
|
+
* `isScopedPackageSpec` first: item configs are authored content, and a
|
|
6
|
+
* version such as `1.0.0/../../../gh/owner/repo@main` would otherwise
|
|
7
|
+
* resolve to a path outside the package.
|
|
8
|
+
*/
|
|
9
|
+
const SEMVER_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
10
|
+
const DIST_TAG = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
11
|
+
const SCOPED_PACKAGE_NAME = /^@[a-z0-9~-][a-z0-9._~-]*\/[a-z0-9~-][a-z0-9._~-]*$/;
|
|
12
|
+
/**
|
|
13
|
+
* A scoped npm package name with an optional semver version or dist-tag, as
|
|
14
|
+
* item configs store it: `@pie-element/rubric`, `@pie-element/rubric@latest`,
|
|
15
|
+
* `@pie-element/rubric@8.0.0-hotfix.2`.
|
|
16
|
+
*/
|
|
17
|
+
export function isScopedPackageSpec(spec) {
|
|
18
|
+
if (typeof spec !== "string")
|
|
19
|
+
return false;
|
|
20
|
+
const versionAt = spec.indexOf("@", 1);
|
|
21
|
+
if (versionAt === -1)
|
|
22
|
+
return SCOPED_PACKAGE_NAME.test(spec);
|
|
23
|
+
const version = spec.slice(versionAt + 1);
|
|
24
|
+
return (SCOPED_PACKAGE_NAME.test(spec.slice(0, versionAt)) &&
|
|
25
|
+
(SEMVER_VERSION.test(version) || DIST_TAG.test(version)));
|
|
26
|
+
}
|
|
27
|
+
/** Why a loader refused `spec`, for its failure reason. */
|
|
28
|
+
export function invalidPackageSpecMessage(spec) {
|
|
29
|
+
return `Invalid element package spec ${JSON.stringify(String(spec))}: expected a scoped npm package name with an optional semver version or dist-tag`;
|
|
30
|
+
}
|
|
@@ -67,7 +67,8 @@ export declare function createEsmBackend(config: EsmBackendConfig): EsmBackend;
|
|
|
67
67
|
export declare function mapEsmViewElements(elements: ElementMap, view?: string, viewConfig?: ViewConfig): ElementMap;
|
|
68
68
|
/**
|
|
69
69
|
* URL of a package's `./runtime-support` module on the ESM CDN the players load
|
|
70
|
-
* elements from, or `undefined` when a custom provider names no package root
|
|
70
|
+
* elements from, or `undefined` when a custom provider names no package root or
|
|
71
|
+
* `packageVersion` is not a scoped package spec.
|
|
71
72
|
*/
|
|
72
73
|
export declare function resolveEsmRuntimeSupportUrl(packageVersion: string, options: {
|
|
73
74
|
cdnBaseUrl: string;
|
|
@@ -21,6 +21,7 @@ import { BundleType, isCustomElementConstructor, Status, } from "../pie/types.js
|
|
|
21
21
|
import { parsePackageName } from "../pie/utils.js";
|
|
22
22
|
import { AdapterFailure, } from "./element-loader-types.js";
|
|
23
23
|
import { isExactSemver } from "./element-package-policy.js";
|
|
24
|
+
import { invalidPackageSpecMessage, isScopedPackageSpec, } from "./element-package-spec.js";
|
|
24
25
|
import { forwardMathjaxEvents } from "./mathjax-events.js";
|
|
25
26
|
import { isPlainRecord } from "../object/index.js";
|
|
26
27
|
export const BUILT_IN_VIEWS = {
|
|
@@ -80,12 +81,30 @@ export function createEsmBackend(config) {
|
|
|
80
81
|
async function load(elements, context) {
|
|
81
82
|
if (!elements || Object.keys(elements).length === 0)
|
|
82
83
|
return;
|
|
84
|
+
// Refused before any URL is built from the spec; the other elements load.
|
|
85
|
+
const reasons = new Map();
|
|
86
|
+
const loadable = {};
|
|
87
|
+
for (const [tag, pkg] of Object.entries(elements)) {
|
|
88
|
+
if (isScopedPackageSpec(pkg)) {
|
|
89
|
+
loadable[tag] = pkg;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
reasons.set(tag, {
|
|
93
|
+
kind: "backend-rejected",
|
|
94
|
+
tag,
|
|
95
|
+
cause: invalidPackageSpecMessage(pkg),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (Object.keys(loadable).length === 0) {
|
|
100
|
+
throw new AdapterFailure(reasons);
|
|
101
|
+
}
|
|
83
102
|
if (moduleResolution === "import-map") {
|
|
84
103
|
assertImportMapSupported();
|
|
85
104
|
}
|
|
86
105
|
forwardMathjaxEventsOf(context.doc);
|
|
87
106
|
const newEntries = {};
|
|
88
|
-
for (const [tag, pkg] of Object.entries(
|
|
107
|
+
for (const [tag, pkg] of Object.entries(loadable)) {
|
|
89
108
|
const packageName = parsePackageName(pkg).name;
|
|
90
109
|
if (moduleResolution === "import-map") {
|
|
91
110
|
const existingVersion = importMappedPackageVersions.get(packageName);
|
|
@@ -147,8 +166,7 @@ export function createEsmBackend(config) {
|
|
|
147
166
|
await applyImportMap(imports, injectedMap, context.doc);
|
|
148
167
|
}
|
|
149
168
|
}
|
|
150
|
-
|
|
151
|
-
await Promise.all(Object.entries(elements).map(async ([tag, packageVersion]) => {
|
|
169
|
+
await Promise.all(Object.entries(loadable).map(async ([tag, packageVersion]) => {
|
|
152
170
|
const packageName = parsePackageName(packageVersion).name;
|
|
153
171
|
let actualTag;
|
|
154
172
|
try {
|
|
@@ -609,9 +627,12 @@ async function fetchPackageMetadata(packageJsonUrl) {
|
|
|
609
627
|
const RUNTIME_SUPPORT_PATH = "dist/runtime-support.js";
|
|
610
628
|
/**
|
|
611
629
|
* URL of a package's `./runtime-support` module on the ESM CDN the players load
|
|
612
|
-
* elements from, or `undefined` when a custom provider names no package root
|
|
630
|
+
* elements from, or `undefined` when a custom provider names no package root or
|
|
631
|
+
* `packageVersion` is not a scoped package spec.
|
|
613
632
|
*/
|
|
614
633
|
export function resolveEsmRuntimeSupportUrl(packageVersion, options) {
|
|
634
|
+
if (!isScopedPackageSpec(packageVersion))
|
|
635
|
+
return undefined;
|
|
615
636
|
const cdnBaseUrl = options.cdnBaseUrl.replace(/\/+$/, "");
|
|
616
637
|
const provider = resolveCdnProvider(options.cdnProvider, cdnBaseUrl);
|
|
617
638
|
if (typeof provider.runtimeSupportUrl === "function") {
|
package/dist/loaders/index.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export type { ElementMap } from "./ElementLoader.js";
|
|
|
34
34
|
export { aggregateElements } from "./ElementLoader.js";
|
|
35
35
|
export type { ElementPackagePolicy } from "./element-package-policy.js";
|
|
36
36
|
export { assertElementPackagesAllowed, ElementPackagePolicyError, } from "./element-package-policy.js";
|
|
37
|
+
export { isScopedPackageSpec } from "./element-package-spec.js";
|
|
37
38
|
export type { BackendContext, BackendOption, ElementLoaderBackend, ElementTag, EnsureRegisteredOptions, EsmBackendConfig, IifeBackendConfig, RegistrationFailureReason, } from "./element-loader.js";
|
|
38
39
|
export { AdapterFailure, ElementAssertionError, ElementLoaderError, assertRegistered, describeRegistrationFailures, ensureRegistered, } from "./element-loader.js";
|
|
39
40
|
export type { BuiltInEsmCdnProviderName, EsmBackend, EsmCdnProvider, EsmCdnProviderName, EsmCdnProviderOption, ViewConfig, } from "./esm-adapter.js";
|
package/dist/loaders/index.js
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
export { aggregateElements } from "./ElementLoader.js";
|
|
34
34
|
export { assertElementPackagesAllowed, ElementPackagePolicyError, } from "./element-package-policy.js";
|
|
35
|
+
export { isScopedPackageSpec } from "./element-package-spec.js";
|
|
35
36
|
export { AdapterFailure, ElementAssertionError, ElementLoaderError, assertRegistered, describeRegistrationFailures, ensureRegistered, } from "./element-loader.js";
|
|
36
37
|
export { BUILT_IN_VIEWS, createEsmBackend, mapEsmViewElements, resolveEsmAssetRoot, resolveEsmRuntimeSupportUrl, } from "./esm-adapter.js";
|
|
37
38
|
export { forwardMathjaxEvents, MATHJAX_NO_ASSET_ROOT_EVENT, MATHJAX_VERSION_CONFLICT_EVENT, } from "./mathjax-events.js";
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* define the ones content names under another base tag
|
|
12
12
|
* (`defineAuthoredPreloadedTags`) and assert them (`assertRegistered`).
|
|
13
13
|
*
|
|
14
|
+
* pie-elements-ng's `./browser/*` modules export `piePackage`, the
|
|
15
|
+
* `{ name, version }` of the package they were built from, so an entry can
|
|
16
|
+
* give its modules alone. A `package` or `version` it also gives must match,
|
|
17
|
+
* which catches a registration an upgrade left behind.
|
|
18
|
+
*
|
|
14
19
|
* A hosted player takes models from the server and resolves no controller. A
|
|
15
20
|
* player that is not hosted runs the controller's `model()` in the browser, so
|
|
16
21
|
* it needs the `controller` an entry registers; the item player warns about
|
|
@@ -29,13 +34,20 @@ export interface PreloadedElement {
|
|
|
29
34
|
/**
|
|
30
35
|
* Base tag to register, e.g. `pie-element-multiple-choice`. Content can
|
|
31
36
|
* author another base tag for the package, which the players define from
|
|
32
|
-
* this registration.
|
|
37
|
+
* this registration. Defaults to the package name without its `@`, with
|
|
38
|
+
* `-` for `/`.
|
|
39
|
+
*/
|
|
40
|
+
tag?: string;
|
|
41
|
+
/**
|
|
42
|
+
* npm package name, e.g. `@pie-element/multiple-choice`. Defaults to the
|
|
43
|
+
* name in the modules' `piePackage`.
|
|
44
|
+
*/
|
|
45
|
+
package?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The installed version, exact, e.g. `13.4.4`. Defaults to the version in
|
|
48
|
+
* the modules' `piePackage`.
|
|
33
49
|
*/
|
|
34
|
-
|
|
35
|
-
/** npm package name, e.g. `@pie-element/multiple-choice`. */
|
|
36
|
-
package: string;
|
|
37
|
-
/** The installed version, exact, e.g. `13.4.4`. */
|
|
38
|
-
version: string;
|
|
50
|
+
version?: string;
|
|
39
51
|
/** The package's `./browser/delivery` module, or its default export. */
|
|
40
52
|
element: CustomElementConstructor | {
|
|
41
53
|
readonly default: CustomElementConstructor;
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* define the ones content names under another base tag
|
|
12
12
|
* (`defineAuthoredPreloadedTags`) and assert them (`assertRegistered`).
|
|
13
13
|
*
|
|
14
|
+
* pie-elements-ng's `./browser/*` modules export `piePackage`, the
|
|
15
|
+
* `{ name, version }` of the package they were built from, so an entry can
|
|
16
|
+
* give its modules alone. A `package` or `version` it also gives must match,
|
|
17
|
+
* which catches a registration an upgrade left behind.
|
|
18
|
+
*
|
|
14
19
|
* A hosted player takes models from the server and resolves no controller. A
|
|
15
20
|
* player that is not hosted runs the controller's `model()` in the browser, so
|
|
16
21
|
* it needs the `controller` an entry registers; the item player warns about
|
|
@@ -23,6 +28,7 @@
|
|
|
23
28
|
import { defineCustomElementSafely } from "../pie/custom-element-define.js";
|
|
24
29
|
import { mathAssetOptionsError, setMathAssetOptions, } from "../pie/math-assets.js";
|
|
25
30
|
import { writeRegistryEntry } from "../pie/registry.js";
|
|
31
|
+
import { validateCustomElementTag } from "../pie/tag-names.js";
|
|
26
32
|
import { BundleType, isCustomElementConstructor, Status } from "../pie/types.js";
|
|
27
33
|
import { parsePackageName } from "../pie/utils.js";
|
|
28
34
|
import { toPackageVersionedTag } from "../pie/versioned-tag.js";
|
|
@@ -69,15 +75,24 @@ export function registerPreloadedElements(elements, options = {}) {
|
|
|
69
75
|
function resolveElement(element, index) {
|
|
70
76
|
const at = `[registerPreloadedElements] entry ${index}`;
|
|
71
77
|
if (!element || typeof element !== "object") {
|
|
72
|
-
throw new Error(`${at}: expected {
|
|
78
|
+
throw new Error(`${at}: expected { element, controller?, tag?, package?, version? }`);
|
|
79
|
+
}
|
|
80
|
+
const identity = moduleIdentity(element, at);
|
|
81
|
+
if (!identity && element.package === undefined && element.version === undefined) {
|
|
82
|
+
throw new Error(`${at}: package and version are required for modules that export no piePackage`);
|
|
73
83
|
}
|
|
74
|
-
const
|
|
84
|
+
const packageName = element.package ?? identity?.name;
|
|
85
|
+
const version = element.version ?? identity?.version;
|
|
75
86
|
if (!isBarePackageName(packageName)) {
|
|
76
87
|
throw new Error(`${at}: package must be a bare package name, got ${JSON.stringify(packageName)}`);
|
|
77
88
|
}
|
|
78
89
|
if (typeof version !== "string" || !isExactSemver(version)) {
|
|
79
90
|
throw new Error(`${at} (${packageName}): version must be the installed version, exact, such as 1.2.3; got ${JSON.stringify(version)}`);
|
|
80
91
|
}
|
|
92
|
+
if (identity && (identity.name !== packageName || identity.version !== version)) {
|
|
93
|
+
throw new Error(`${at}: the modules were built from ${identity.name}@${identity.version}, not ${packageName}@${version}`);
|
|
94
|
+
}
|
|
95
|
+
const tag = element.tag ?? packageName.replace(/^@/, "").replace("/", "-");
|
|
81
96
|
if (typeof tag !== "string" || !tag) {
|
|
82
97
|
throw new Error(`${at} (${packageName}): tag must be the base tag to register, got ${JSON.stringify(tag)}`);
|
|
83
98
|
}
|
|
@@ -95,13 +110,33 @@ function resolveElement(element, index) {
|
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
112
|
const spec = `${packageName}@${version}`;
|
|
98
|
-
|
|
99
|
-
packageName
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
113
|
+
const tagName = validateCustomElementTag(toPackageVersionedTag(tag, spec), element.tag === undefined
|
|
114
|
+
? `${at} (${packageName}) tag derived from the package name; give tag`
|
|
115
|
+
: `${at} (${packageName}) tag`);
|
|
116
|
+
return { packageName, spec, tagName, elementClass: candidate, controller };
|
|
117
|
+
}
|
|
118
|
+
/** The `piePackage` of the entry's modules, which agree when both export one. */
|
|
119
|
+
function moduleIdentity(element, at) {
|
|
120
|
+
const fromElement = readPackageIdentity(element.element, `${at}: element`);
|
|
121
|
+
const fromController = readPackageIdentity(element.controller, `${at}: controller`);
|
|
122
|
+
if (fromElement &&
|
|
123
|
+
fromController &&
|
|
124
|
+
(fromElement.name !== fromController.name ||
|
|
125
|
+
fromElement.version !== fromController.version)) {
|
|
126
|
+
throw new Error(`${at}: the element module is ${fromElement.name}@${fromElement.version}, ` +
|
|
127
|
+
`but the controller module is ${fromController.name}@${fromController.version}`);
|
|
128
|
+
}
|
|
129
|
+
return fromElement ?? fromController;
|
|
130
|
+
}
|
|
131
|
+
function readPackageIdentity(module, at) {
|
|
132
|
+
if (!module || typeof module !== "object" || !("piePackage" in module)) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
const identity = module.piePackage;
|
|
136
|
+
if (typeof identity?.name !== "string" || typeof identity.version !== "string") {
|
|
137
|
+
throw new Error(`${at} module's piePackage must be { name, version }`);
|
|
138
|
+
}
|
|
139
|
+
return { name: identity.name, version: identity.version };
|
|
105
140
|
}
|
|
106
141
|
function resolveController(value) {
|
|
107
142
|
if (!value || typeof value !== "object")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waits for every value to settle and resolves with each outcome in input
|
|
3
|
+
* order, as the `allSettled` static does. Built on `Promise.all` and `then` alone:
|
|
4
|
+
* a host page can replace the global `Promise` with an implementation that
|
|
5
|
+
* predates `allSettled` (zone.js 0.9.1 does), so published code calls no
|
|
6
|
+
* `Promise` static beyond `all`, `race`, `resolve` and `reject`.
|
|
7
|
+
* `bun run check:bundle-safety` enforces that on every built bundle.
|
|
8
|
+
* @param values The promises or plain values to settle.
|
|
9
|
+
*/
|
|
10
|
+
export declare const settleAll: <T>(values: Iterable<T | PromiseLike<T>>) => Promise<PromiseSettledResult<Awaited<T>>[]>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waits for every value to settle and resolves with each outcome in input
|
|
3
|
+
* order, as the `allSettled` static does. Built on `Promise.all` and `then` alone:
|
|
4
|
+
* a host page can replace the global `Promise` with an implementation that
|
|
5
|
+
* predates `allSettled` (zone.js 0.9.1 does), so published code calls no
|
|
6
|
+
* `Promise` static beyond `all`, `race`, `resolve` and `reject`.
|
|
7
|
+
* `bun run check:bundle-safety` enforces that on every built bundle.
|
|
8
|
+
* @param values The promises or plain values to settle.
|
|
9
|
+
*/
|
|
10
|
+
export const settleAll = (values) => Promise.all(Array.from(values, (value) => Promise.resolve(value).then((result) => ({
|
|
11
|
+
status: "fulfilled",
|
|
12
|
+
value: result,
|
|
13
|
+
}), (reason) => ({
|
|
14
|
+
status: "rejected",
|
|
15
|
+
reason,
|
|
16
|
+
}))));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps the image and table scroll wrappers under a live root in step with
|
|
3
|
+
* whether they scroll. See {@link syncOverwideRegions}.
|
|
4
|
+
*
|
|
5
|
+
* Overflow changes without any mutation: zoom and a resized column change the
|
|
6
|
+
* wrapper's width, an image load or a dropped drag tile changes its content's.
|
|
7
|
+
* A `ResizeObserver` on each wrapper and its content catches both, and its first
|
|
8
|
+
* delivery for a newly observed target is the initial sync. A wrapper that is
|
|
9
|
+
* not laid out measures zero and syncs as non-scrolling; showing it resizes it,
|
|
10
|
+
* which syncs it again.
|
|
11
|
+
*
|
|
12
|
+
* Without `ResizeObserver` nothing is synced, so every wrapper stays a region —
|
|
13
|
+
* the reachable default.
|
|
14
|
+
*/
|
|
15
|
+
export interface OverwideRegionTracker {
|
|
16
|
+
/** Starts observing every wrapper under the root not yet observed. */
|
|
17
|
+
track(): void;
|
|
18
|
+
disconnect(): void;
|
|
19
|
+
}
|
|
20
|
+
export declare function createOverwideRegionTracker(root: Element): OverwideRegionTracker;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps the image and table scroll wrappers under a live root in step with
|
|
3
|
+
* whether they scroll. See {@link syncOverwideRegions}.
|
|
4
|
+
*
|
|
5
|
+
* Overflow changes without any mutation: zoom and a resized column change the
|
|
6
|
+
* wrapper's width, an image load or a dropped drag tile changes its content's.
|
|
7
|
+
* A `ResizeObserver` on each wrapper and its content catches both, and its first
|
|
8
|
+
* delivery for a newly observed target is the initial sync. A wrapper that is
|
|
9
|
+
* not laid out measures zero and syncs as non-scrolling; showing it resizes it,
|
|
10
|
+
* which syncs it again.
|
|
11
|
+
*
|
|
12
|
+
* Without `ResizeObserver` nothing is synced, so every wrapper stays a region —
|
|
13
|
+
* the reachable default.
|
|
14
|
+
*/
|
|
15
|
+
import { IMAGE_SPEC } from "./wrap-overwide-images.js";
|
|
16
|
+
import { TABLE_SPEC } from "./wrap-overwide-tables.js";
|
|
17
|
+
import { syncOverwideRegions } from "./wrap-overwide.js";
|
|
18
|
+
const SPECS = [IMAGE_SPEC, TABLE_SPEC];
|
|
19
|
+
const WRAPPER_SELECTOR = SPECS.map((spec) => `.${spec.wrapperClass}`).join(",");
|
|
20
|
+
export function createOverwideRegionTracker(root) {
|
|
21
|
+
if (typeof ResizeObserver === "undefined") {
|
|
22
|
+
return { track() { }, disconnect() { } };
|
|
23
|
+
}
|
|
24
|
+
// Wrapper to the content observed with it. Held strongly, as the observer
|
|
25
|
+
// holds its targets, so each `track()` releases wrappers that have left the
|
|
26
|
+
// document — an element re-rendering over its subtree drops them every time.
|
|
27
|
+
const observed = new Map();
|
|
28
|
+
const observer = new ResizeObserver((entries) => {
|
|
29
|
+
const wrappers = new Set();
|
|
30
|
+
for (const { target } of entries) {
|
|
31
|
+
const wrapper = target.matches(WRAPPER_SELECTOR)
|
|
32
|
+
? target
|
|
33
|
+
: target.parentElement;
|
|
34
|
+
if (wrapper)
|
|
35
|
+
wrappers.add(wrapper);
|
|
36
|
+
}
|
|
37
|
+
for (const spec of SPECS)
|
|
38
|
+
syncOverwideRegions(wrappers, spec);
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
track() {
|
|
42
|
+
for (const [wrapper, content] of observed) {
|
|
43
|
+
if (wrapper.isConnected)
|
|
44
|
+
continue;
|
|
45
|
+
observer.unobserve(wrapper);
|
|
46
|
+
if (content)
|
|
47
|
+
observer.unobserve(content);
|
|
48
|
+
observed.delete(wrapper);
|
|
49
|
+
}
|
|
50
|
+
for (const wrapper of Array.from(root.querySelectorAll(WRAPPER_SELECTOR))) {
|
|
51
|
+
if (observed.has(wrapper))
|
|
52
|
+
continue;
|
|
53
|
+
const content = wrapper.firstElementChild;
|
|
54
|
+
observed.set(wrapper, content);
|
|
55
|
+
observer.observe(wrapper);
|
|
56
|
+
if (content)
|
|
57
|
+
observer.observe(content);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
disconnect() {
|
|
61
|
+
observer.disconnect();
|
|
62
|
+
observed.clear();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The wrapper is rendered as
|
|
7
7
|
* `<span class="pie-image-scroll" tabindex="0" role="region" aria-label="...">`
|
|
8
|
-
* and receives the accompanying CSS from `@pie-players/pie-theme`.
|
|
8
|
+
* and receives the accompanying CSS from `@pie-players/pie-theme`.
|
|
9
|
+
* The live player keeps the region attributes only while the wrapper actually
|
|
10
|
+
* scrolls (see `./overwide-regions.js`), so a image that fits adds no tab stop. The CSS uses
|
|
9
11
|
* `overflow-x: auto` so small images stay visually unchanged: a scrollbar only
|
|
10
12
|
* appears when the image's intrinsic width exceeds the wrapper's available
|
|
11
13
|
* space (including at higher browser-zoom levels, which is the original driver
|
|
@@ -26,7 +28,9 @@
|
|
|
26
28
|
* The wrapping itself lives in `./wrap-overwide.js`, shared with the table
|
|
27
29
|
* wrapper: only the spec below differs.
|
|
28
30
|
*/
|
|
29
|
-
import { type WrapOverwideOptions } from "./wrap-overwide.js";
|
|
31
|
+
import { type OverwideWrapSpec, type WrapOverwideOptions } from "./wrap-overwide.js";
|
|
32
|
+
/** Internal: shared with the region tracker, not re-exported. */
|
|
33
|
+
export declare const IMAGE_SPEC: OverwideWrapSpec;
|
|
30
34
|
export type WrapOverwideImagesInElementOptions = WrapOverwideOptions;
|
|
31
35
|
/**
|
|
32
36
|
* Wrap every unwrapped `<img>` descendant of `root` with the shared
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The wrapper is rendered as
|
|
7
7
|
* `<span class="pie-image-scroll" tabindex="0" role="region" aria-label="...">`
|
|
8
|
-
* and receives the accompanying CSS from `@pie-players/pie-theme`.
|
|
8
|
+
* and receives the accompanying CSS from `@pie-players/pie-theme`.
|
|
9
|
+
* The live player keeps the region attributes only while the wrapper actually
|
|
10
|
+
* scrolls (see `./overwide-regions.js`), so a image that fits adds no tab stop. The CSS uses
|
|
9
11
|
* `overflow-x: auto` so small images stay visually unchanged: a scrollbar only
|
|
10
12
|
* appears when the image's intrinsic width exceeds the wrapper's available
|
|
11
13
|
* space (including at higher browser-zoom levels, which is the original driver
|
|
@@ -50,7 +52,8 @@ function imageLayoutClass(image) {
|
|
|
50
52
|
display !== "contents";
|
|
51
53
|
return blockLevel ? "pie-image-scroll-block" : null;
|
|
52
54
|
}
|
|
53
|
-
|
|
55
|
+
/** Internal: shared with the region tracker, not re-exported. */
|
|
56
|
+
export const IMAGE_SPEC = {
|
|
54
57
|
selector: "img",
|
|
55
58
|
wrapperTag: "span",
|
|
56
59
|
wrapperClass: "pie-image-scroll",
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The wrapper is rendered as
|
|
7
7
|
* `<div class="pie-table-scroll" tabindex="0" role="region" aria-label="...">`
|
|
8
|
-
* and receives the accompanying CSS from `@pie-players/pie-theme`.
|
|
8
|
+
* and receives the accompanying CSS from `@pie-players/pie-theme`.
|
|
9
|
+
* The live player keeps the region attributes only while the wrapper actually
|
|
10
|
+
* scrolls (see `./overwide-regions.js`), so a table that fits adds no tab stop. The CSS uses
|
|
9
11
|
* `overflow-x: auto` so narrow tables stay visually unchanged: a scrollbar only
|
|
10
12
|
* appears when the table's intrinsic width exceeds the wrapper's available
|
|
11
13
|
* space (including at higher browser-zoom levels — WCAG 1.4.10 Reflow at 400%
|
|
@@ -26,7 +28,9 @@
|
|
|
26
28
|
* The wrapping itself lives in `./wrap-overwide.js`, shared with the image
|
|
27
29
|
* wrapper: only the four values below and the accessible name differ.
|
|
28
30
|
*/
|
|
29
|
-
import { type WrapOverwideOptions } from "./wrap-overwide.js";
|
|
31
|
+
import { type OverwideWrapSpec, type WrapOverwideOptions } from "./wrap-overwide.js";
|
|
32
|
+
/** Internal: shared with the region tracker, not re-exported. */
|
|
33
|
+
export declare const TABLE_SPEC: OverwideWrapSpec;
|
|
30
34
|
export type WrapOverwideTablesInElementOptions = WrapOverwideOptions;
|
|
31
35
|
/**
|
|
32
36
|
* Wrap every unwrapped `<table>` descendant of `root` with the shared
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The wrapper is rendered as
|
|
7
7
|
* `<div class="pie-table-scroll" tabindex="0" role="region" aria-label="...">`
|
|
8
|
-
* and receives the accompanying CSS from `@pie-players/pie-theme`.
|
|
8
|
+
* and receives the accompanying CSS from `@pie-players/pie-theme`.
|
|
9
|
+
* The live player keeps the region attributes only while the wrapper actually
|
|
10
|
+
* scrolls (see `./overwide-regions.js`), so a table that fits adds no tab stop. The CSS uses
|
|
9
11
|
* `overflow-x: auto` so narrow tables stay visually unchanged: a scrollbar only
|
|
10
12
|
* appears when the table's intrinsic width exceeds the wrapper's available
|
|
11
13
|
* space (including at higher browser-zoom levels — WCAG 1.4.10 Reflow at 400%
|
|
@@ -57,7 +59,8 @@ function buildAriaLabel(table) {
|
|
|
57
59
|
}
|
|
58
60
|
return "Scrollable table";
|
|
59
61
|
}
|
|
60
|
-
|
|
62
|
+
/** Internal: shared with the region tracker, not re-exported. */
|
|
63
|
+
export const TABLE_SPEC = {
|
|
61
64
|
selector: "table",
|
|
62
65
|
wrapperTag: "div",
|
|
63
66
|
wrapperClass: "pie-table-scroll",
|
|
@@ -55,6 +55,19 @@ export interface WrapOverwideOptions {
|
|
|
55
55
|
* the layout class this pass would have given it.
|
|
56
56
|
*/
|
|
57
57
|
export declare function wrapOverwideInElement(root: Element, spec: OverwideWrapSpec, options?: WrapOverwideOptions): number;
|
|
58
|
+
/**
|
|
59
|
+
* Makes each wrapper a named, focusable region while its content overflows it
|
|
60
|
+
* horizontally, and a plain container otherwise.
|
|
61
|
+
*
|
|
62
|
+
* A wrapper is built as a region, because a wrapper that scrolls must be
|
|
63
|
+
* reachable by keyboard (WCAG 2.1.1) and markup wrapped off-document cannot be
|
|
64
|
+
* measured. Most never scroll: a table that fits its column, a drag tile, a
|
|
65
|
+
* layout table. Left as regions they add a tab stop and a landmark each, so the
|
|
66
|
+
* live player measures every wrapper and keeps the region only where there is
|
|
67
|
+
* something to scroll. Measurements are read for every wrapper before the first
|
|
68
|
+
* write. A wrapper holding focus keeps its region, so a sync never drops focus.
|
|
69
|
+
*/
|
|
70
|
+
export declare function syncOverwideRegions(wrappers: Iterable<Element>, spec: OverwideWrapSpec): void;
|
|
58
71
|
/**
|
|
59
72
|
* String-in / string-out wrapping, for the post-sanitization step.
|
|
60
73
|
*
|
|
@@ -40,6 +40,29 @@ function isInsidePieCustomElement(node, root) {
|
|
|
40
40
|
}
|
|
41
41
|
return false;
|
|
42
42
|
}
|
|
43
|
+
const VERTICAL_MARGINS = ["margin-top", "margin-bottom"];
|
|
44
|
+
/**
|
|
45
|
+
* Moves the node's inline vertical margins onto its wrapper. The wrapper clips
|
|
46
|
+
* vertically — `overflow-x: auto` forces `overflow-y` off `visible` — so a
|
|
47
|
+
* negative margin left on the node pulls its edge into the clipped band, and
|
|
48
|
+
* the wrapper's own block formatting context stops a positive one collapsing
|
|
49
|
+
* with its neighbours. On the wrapper, both lay out as they did unwrapped.
|
|
50
|
+
* Horizontal margins stay: `margin: 0 auto` centres the node inside the wrapper.
|
|
51
|
+
*/
|
|
52
|
+
function liftVerticalMargins(node, wrapper) {
|
|
53
|
+
const style = node.style;
|
|
54
|
+
if (!style)
|
|
55
|
+
return;
|
|
56
|
+
for (const property of VERTICAL_MARGINS) {
|
|
57
|
+
const value = style.getPropertyValue(property);
|
|
58
|
+
if (!value)
|
|
59
|
+
continue;
|
|
60
|
+
wrapper.style.setProperty(property, value, style.getPropertyPriority(property));
|
|
61
|
+
style.removeProperty(property);
|
|
62
|
+
}
|
|
63
|
+
if (!style.length)
|
|
64
|
+
node.removeAttribute("style");
|
|
65
|
+
}
|
|
43
66
|
/**
|
|
44
67
|
* Wrappers whose layout class was read from the live cascade. A wrapper built
|
|
45
68
|
* off-document saw only its node's inline style, so the first live pass to meet
|
|
@@ -109,17 +132,59 @@ export function wrapOverwideInElement(root, spec, options = {}) {
|
|
|
109
132
|
wrapper.className = layoutClass
|
|
110
133
|
? `${spec.wrapperClass} ${layoutClass}`
|
|
111
134
|
: spec.wrapperClass;
|
|
112
|
-
wrapper
|
|
113
|
-
wrapper.setAttribute("role", "region");
|
|
114
|
-
wrapper.setAttribute("aria-label", spec.buildAriaLabel(node));
|
|
135
|
+
setRegion(wrapper, node, spec);
|
|
115
136
|
if (readsLiveLayout)
|
|
116
137
|
liveLayoutRead.add(wrapper);
|
|
138
|
+
liftVerticalMargins(node, wrapper);
|
|
117
139
|
parent.insertBefore(wrapper, node);
|
|
118
140
|
wrapper.appendChild(node);
|
|
119
141
|
wrapped += 1;
|
|
120
142
|
}
|
|
121
143
|
return wrapped;
|
|
122
144
|
}
|
|
145
|
+
function setRegion(wrapper, node, spec) {
|
|
146
|
+
wrapper.setAttribute("tabindex", "0");
|
|
147
|
+
wrapper.setAttribute("role", "region");
|
|
148
|
+
wrapper.setAttribute("aria-label", spec.buildAriaLabel(node));
|
|
149
|
+
}
|
|
150
|
+
function clearRegion(wrapper) {
|
|
151
|
+
wrapper.removeAttribute("tabindex");
|
|
152
|
+
wrapper.removeAttribute("role");
|
|
153
|
+
wrapper.removeAttribute("aria-label");
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Makes each wrapper a named, focusable region while its content overflows it
|
|
157
|
+
* horizontally, and a plain container otherwise.
|
|
158
|
+
*
|
|
159
|
+
* A wrapper is built as a region, because a wrapper that scrolls must be
|
|
160
|
+
* reachable by keyboard (WCAG 2.1.1) and markup wrapped off-document cannot be
|
|
161
|
+
* measured. Most never scroll: a table that fits its column, a drag tile, a
|
|
162
|
+
* layout table. Left as regions they add a tab stop and a landmark each, so the
|
|
163
|
+
* live player measures every wrapper and keeps the region only where there is
|
|
164
|
+
* something to scroll. Measurements are read for every wrapper before the first
|
|
165
|
+
* write. A wrapper holding focus keeps its region, so a sync never drops focus.
|
|
166
|
+
*/
|
|
167
|
+
export function syncOverwideRegions(wrappers, spec) {
|
|
168
|
+
const measured = [];
|
|
169
|
+
for (const wrapper of wrappers) {
|
|
170
|
+
if (!wrapper.isConnected)
|
|
171
|
+
continue;
|
|
172
|
+
if (!wrapper.classList.contains(spec.wrapperClass))
|
|
173
|
+
continue;
|
|
174
|
+
measured.push([wrapper, wrapper.scrollWidth > wrapper.clientWidth]);
|
|
175
|
+
}
|
|
176
|
+
for (const [wrapper, scrolls] of measured) {
|
|
177
|
+
const node = wrapper.firstElementChild;
|
|
178
|
+
if (scrolls && node?.matches(spec.selector))
|
|
179
|
+
setRegion(wrapper, node, spec);
|
|
180
|
+
else if (!scrolls && !isFocused(wrapper))
|
|
181
|
+
clearRegion(wrapper);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function isFocused(element) {
|
|
185
|
+
const root = element.getRootNode();
|
|
186
|
+
return root.activeElement === element;
|
|
187
|
+
}
|
|
123
188
|
/**
|
|
124
189
|
* String-in / string-out wrapping, for the post-sanitization step.
|
|
125
190
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pie-players/pie-players-shared",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shared runtime + UI utilities for PIE players",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
"types": "./dist/object/index.d.ts",
|
|
34
34
|
"import": "./dist/object/index.js"
|
|
35
35
|
},
|
|
36
|
+
"./promise": {
|
|
37
|
+
"types": "./dist/promise/index.d.ts",
|
|
38
|
+
"import": "./dist/promise/index.js"
|
|
39
|
+
},
|
|
36
40
|
"./types": {
|
|
37
41
|
"types": "./dist/types/index.d.ts",
|
|
38
42
|
"import": "./dist/types/index.js"
|
|
@@ -121,12 +125,12 @@
|
|
|
121
125
|
"@biomejs/biome": "^2.5.15",
|
|
122
126
|
"@happy-dom/global-registrator": "^20.14.5",
|
|
123
127
|
"@pie-element/shared-math-rendering-mathjax": "0.1.4",
|
|
124
|
-
"@playwright/test": "^1.
|
|
128
|
+
"@playwright/test": "^1.64.0",
|
|
125
129
|
"@types/semver": "^7.8.0",
|
|
126
130
|
"es-module-shims": "^2.8.4",
|
|
127
131
|
"esbuild": "^0.28.2",
|
|
128
132
|
"glob": "^13.0.0",
|
|
129
|
-
"svelte": "^5.57.
|
|
133
|
+
"svelte": "^5.57.2",
|
|
130
134
|
"svelte-check": "^4.7.6",
|
|
131
135
|
"typescript": "^5.9.3"
|
|
132
136
|
},
|