@pie-players/pie-players-shared 1.0.0-beta.2 → 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.
@@ -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,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.2",
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",