@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.
@@ -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 Promise.allSettled(this.providers.map((provider) => provider.initialize(config)));
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(elements)) {
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
- const reasons = new Map();
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") {
@@ -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";
@@ -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
- tag: string;
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 { tag, package, version, element }`);
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 { package: packageName, version, tag } = element;
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
- return {
99
- packageName,
100
- spec,
101
- tagName: toPackageVersionedTag(tag, spec),
102
- elementClass: candidate,
103
- controller,
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`. The CSS uses
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`. The CSS uses
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
- const IMAGE_SPEC = {
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`. The CSS uses
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`. The CSS uses
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
- const TABLE_SPEC = {
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.setAttribute("tabindex", "0");
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.1",
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.63.0",
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.1",
133
+ "svelte": "^5.57.2",
130
134
  "svelte-check": "^4.7.6",
131
135
  "typescript": "^5.9.3"
132
136
  },