@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.
- package/dist/loaders/preloaded-registration.d.ts +18 -6
- package/dist/loaders/preloaded-registration.js +44 -9
- 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 +1 -1
|
@@ -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,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
|
*
|