@pie-players/pie-players-shared 1.0.0-beta.2 → 1.0.0-beta.4

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.
Files changed (43) hide show
  1. package/dist/i18n/catalogs.d.ts +5 -0
  2. package/dist/i18n/catalogs.js +9 -0
  3. package/dist/i18n/content-language.d.ts +35 -0
  4. package/dist/i18n/content-language.js +51 -0
  5. package/dist/i18n/index.d.ts +6 -2
  6. package/dist/i18n/index.js +5 -2
  7. package/dist/i18n/language-tags.d.ts +9 -0
  8. package/dist/i18n/language-tags.js +21 -0
  9. package/dist/i18n/locale-load-error.d.ts +34 -0
  10. package/dist/i18n/locale-load-error.js +29 -0
  11. package/dist/i18n/messages/en-US.d.ts +113 -3
  12. package/dist/i18n/messages/en-US.js +128 -3
  13. package/dist/i18n/messages/nl-NL.d.ts +103 -2
  14. package/dist/i18n/messages/nl-NL.js +109 -8
  15. package/dist/i18n/provider.d.ts +20 -1
  16. package/dist/i18n/provider.js +71 -31
  17. package/dist/i18n/pseudo.d.ts +34 -0
  18. package/dist/i18n/pseudo.js +106 -0
  19. package/dist/loaders/esm-adapter.d.ts +10 -0
  20. package/dist/loaders/esm-adapter.js +88 -15
  21. package/dist/loaders/index.d.ts +1 -1
  22. package/dist/loaders/index.js +1 -1
  23. package/dist/loaders/preloaded-registration.d.ts +18 -6
  24. package/dist/loaders/preloaded-registration.js +44 -9
  25. package/dist/pie/index.d.ts +1 -0
  26. package/dist/pie/math-rendering.d.ts +13 -1
  27. package/dist/pie/math-rendering.js +54 -1
  28. package/dist/security/overwide-regions.d.ts +20 -0
  29. package/dist/security/overwide-regions.js +65 -0
  30. package/dist/security/wrap-overwide-images.d.ts +6 -2
  31. package/dist/security/wrap-overwide-images.js +5 -2
  32. package/dist/security/wrap-overwide-tables.d.ts +6 -2
  33. package/dist/security/wrap-overwide-tables.js +5 -2
  34. package/dist/security/wrap-overwide.d.ts +13 -0
  35. package/dist/security/wrap-overwide.js +68 -3
  36. package/dist/types/index.d.ts +12 -0
  37. package/dist/ui/focus-trap.d.ts +12 -0
  38. package/dist/ui/focus-trap.js +39 -1
  39. package/dist/ui/overlay-containment.d.ts +13 -0
  40. package/dist/ui/overlay-containment.js +23 -0
  41. package/dist/ui/overlay-placement.d.ts +28 -0
  42. package/dist/ui/overlay-placement.js +17 -2
  43. package/package.json +4 -4
@@ -13,6 +13,16 @@ export interface MathRenderingAPI {
13
13
  unWrapMath?: (wrapped: string) => string;
14
14
  mmlToLatex?: (mathml: string) => string;
15
15
  }
16
+ /** How the default renderer `initializeMathRendering` installs loads its assets. */
17
+ export interface MathRenderingOptions {
18
+ /**
19
+ * The npm root the renderer's speech rules load from when the page's math
20
+ * options set no `assetRoot`. With neither, jsDelivr. A page that names the
21
+ * rules' directory itself, in `SREfeature.json` or a `text/x-sre-config`
22
+ * block, keeps it.
23
+ */
24
+ assetRoot?: string;
25
+ }
16
26
  /**
17
27
  * The page's renderer: the one `initializeMathRendering` installed, or a
18
28
  * host's. IIFE elements render with it, and so do ESM elements when the page
@@ -27,6 +37,8 @@ export declare const getMathRenderer: () => MathRenderingAPI | null;
27
37
  * - window["@pie-lib/math-rendering"] (standard key)
28
38
  * - window["_dll_pie_lib__math_rendering"] (SystemJS/DLL key for IIFE bundles)
29
39
  *
40
+ * The first call installs the renderer for the page, so its `options` apply.
41
+ *
30
42
  * @example
31
43
  * ```typescript
32
44
  * // Default MathJax
@@ -37,7 +49,7 @@ export declare const getMathRenderer: () => MathRenderingAPI | null;
37
49
  * await initializeMathRendering();
38
50
  * ```
39
51
  */
40
- export declare function initializeMathRendering(): Promise<void>;
52
+ export declare function initializeMathRendering(options?: MathRenderingOptions): Promise<void>;
41
53
  /**
42
54
  * Set custom math renderer programmatically
43
55
  *
@@ -8,6 +8,7 @@
8
8
  */
9
9
  /// <reference path="../shims.d.ts" />
10
10
  import { createPieLogger, isGlobalDebugEnabled } from "./logger.js";
11
+ import { pageMathAssetRoot } from "./math-assets.js";
11
12
  const GLOBAL_KEY = "@pie-lib/math-rendering";
12
13
  const GLOBAL_DLL_KEY = "_dll_pie_lib__math_rendering";
13
14
  // Every copy of @pie-lib/math-rendering on a page typesets through the MathJax
@@ -15,6 +16,52 @@ const GLOBAL_DLL_KEY = "_dll_pie_lib__math_rendering";
15
16
  const SHARED_INSTANCE_KEY = "@pie-lib/math-rendering@2";
16
17
  let initPromise = null;
17
18
  const logger = createPieLogger("math-rendering", () => isGlobalDebugEnabled());
19
+ /**
20
+ * The speech-rule-engine version the default renderer bundles, whose rules it
21
+ * loads from `speech-rule-engine@<version>/lib/mathmaps` under an npm root. A
22
+ * test pins it to the module.
23
+ * @internal
24
+ */
25
+ export const DEFAULT_RENDERER_SRE_VERSION = "4.1.2";
26
+ const SRE_FEATURE_KEY = "SREfeature";
27
+ const pageNamesSpeechRules = () => {
28
+ const feature = window[SRE_FEATURE_KEY];
29
+ if (feature && typeof feature === "object" && feature.json)
30
+ return true;
31
+ return Array.from(document.querySelectorAll('script[type="text/x-sre-config"]')).some((block) => {
32
+ try {
33
+ return !!JSON.parse(block.textContent || "")?.json;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ });
39
+ };
40
+ /**
41
+ * Points the module's speech-rule-engine at the rules under `root` and returns
42
+ * the undo. SRE reads `SREfeature` as it sets up, synchronously while the
43
+ * module evaluates, and its later setups keep the path, so the global is
44
+ * restored afterwards: another copy of SRE on the page, as a legacy bundle
45
+ * carries, keeps the rules of its own version.
46
+ */
47
+ const pointSpeechRulesAt = (root) => {
48
+ if (!root || typeof document === "undefined" || pageNamesSpeechRules()) {
49
+ return () => { };
50
+ }
51
+ const host = window;
52
+ const had = Object.hasOwn(host, SRE_FEATURE_KEY);
53
+ const previous = host[SRE_FEATURE_KEY];
54
+ host[SRE_FEATURE_KEY] = {
55
+ ...(previous && typeof previous === "object" ? previous : {}),
56
+ json: `${root.replace(/\/+$/, "")}/speech-rule-engine@${DEFAULT_RENDERER_SRE_VERSION}/lib/mathmaps`,
57
+ };
58
+ return () => {
59
+ if (had)
60
+ host[SRE_FEATURE_KEY] = previous;
61
+ else
62
+ delete host[SRE_FEATURE_KEY];
63
+ };
64
+ };
18
65
  /**
19
66
  * The page's renderer: the one `initializeMathRendering` installed, or a
20
67
  * host's. IIFE elements render with it, and so do ESM elements when the page
@@ -66,6 +113,8 @@ const createSharedInstance = (renderer) => {
66
113
  * - window["@pie-lib/math-rendering"] (standard key)
67
114
  * - window["_dll_pie_lib__math_rendering"] (SystemJS/DLL key for IIFE bundles)
68
115
  *
116
+ * The first call installs the renderer for the page, so its `options` apply.
117
+ *
69
118
  * @example
70
119
  * ```typescript
71
120
  * // Default MathJax
@@ -76,7 +125,7 @@ const createSharedInstance = (renderer) => {
76
125
  * await initializeMathRendering();
77
126
  * ```
78
127
  */
79
- export async function initializeMathRendering() {
128
+ export async function initializeMathRendering(options = {}) {
80
129
  // Only run in browser
81
130
  if (typeof window === "undefined") {
82
131
  return;
@@ -90,6 +139,7 @@ export async function initializeMathRendering() {
90
139
  return;
91
140
  }
92
141
  initPromise = (async () => {
142
+ const restoreSpeechRules = pointSpeechRulesAt(pageMathAssetRoot() ?? options.assetRoot);
93
143
  try {
94
144
  // The package has no exports map, so the file is named in full for
95
145
  // resolvers that do not complete a directory, such as webpack's
@@ -112,6 +162,9 @@ export async function initializeMathRendering() {
112
162
  logger.error("Failed to initialize math rendering:", error);
113
163
  throw error;
114
164
  }
165
+ finally {
166
+ restoreSpeechRules();
167
+ }
115
168
  })();
116
169
  try {
117
170
  await initPromise;
@@ -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
  *
@@ -557,6 +557,10 @@ export interface ToolParameterMap {
557
557
  pitch?: number;
558
558
  volume?: number;
559
559
  };
560
+ /** `zoom` is the lens's starting magnification, 2 meaning twice the size. */
561
+ magnification: {
562
+ zoom?: number;
563
+ };
560
564
  }
561
565
  /** The parameter shape of tool `K`: its {@link ToolParameterMap} entry, else open. */
562
566
  export type ToolParametersFor<K extends string> = K extends keyof ToolParameterMap ? ToolParameterMap[K] : Record<string, unknown>;
@@ -582,6 +586,14 @@ export interface Env {
582
586
  mode: PlayerMode;
583
587
  role: PlayerRole;
584
588
  partialScoring?: boolean;
589
+ /**
590
+ * BCP 47 tag of the language the item's content is written in, when the
591
+ * delivery knows it. The player reflects it onto the content subtree as
592
+ * `lang`/`dir`; a host that emits nothing leaves the content unstamped.
593
+ * The interface locale never travels here — it is the player's `locale`
594
+ * attribute and the `pie.interfaceLocale` context.
595
+ */
596
+ locale?: string;
585
597
  }
586
598
  /** The `detail` of the `player-error` event `pie-item-player` dispatches. */
587
599
  export interface PieItemPlayerErrorDetail {
@@ -24,4 +24,16 @@ type FocusTrapOptions = {
24
24
  * and restores prior focus when the trap is removed.
25
25
  */
26
26
  export declare function createFocusTrap(container: HTMLElement, options?: FocusTrapOptions): () => void;
27
+ /**
28
+ * Returns focus to where it was before a tool took it, for a tool that closes
29
+ * itself: the tool's element leaves the DOM on close, which drops focus to the
30
+ * document body. `capture` runs as the tool takes focus and `restore` as it
31
+ * closes, before its element goes.
32
+ */
33
+ export declare function createFocusReturn(): {
34
+ /** Records the focused element, unless focus is already inside `container`. */
35
+ capture: (container: Node) => void;
36
+ /** Focuses the recorded element, if it is still in the document. */
37
+ restore: () => void;
38
+ };
27
39
  export {};
@@ -44,7 +44,11 @@ export function createFocusTrap(container, options = {}) {
44
44
  const wrap = options.wrap ?? true;
45
45
  const onKeydown = (event) => {
46
46
  if (event.key === "Escape") {
47
- options.onEscape?.();
47
+ if (!options.onEscape)
48
+ return;
49
+ // Claimed, so a page-wide Escape listener does not also act on it.
50
+ event.preventDefault();
51
+ options.onEscape();
48
52
  return;
49
53
  }
50
54
  if (event.key !== "Tab")
@@ -94,3 +98,37 @@ export function createFocusTrap(container, options = {}) {
94
98
  }
95
99
  };
96
100
  }
101
+ /**
102
+ * Returns focus to where it was before a tool took it, for a tool that closes
103
+ * itself: the tool's element leaves the DOM on close, which drops focus to the
104
+ * document body. `capture` runs as the tool takes focus and `restore` as it
105
+ * closes, before its element goes.
106
+ */
107
+ export function createFocusReturn() {
108
+ let opener = null;
109
+ return {
110
+ capture(container) {
111
+ if (typeof document === "undefined")
112
+ return;
113
+ const active = getDeepActiveElement(document);
114
+ if (active && container.contains(active))
115
+ return;
116
+ opener =
117
+ active instanceof HTMLElement && active !== document.body
118
+ ? active
119
+ : null;
120
+ },
121
+ restore() {
122
+ const target = opener;
123
+ opener = null;
124
+ if (!target?.isConnected)
125
+ return;
126
+ try {
127
+ target.focus();
128
+ }
129
+ catch {
130
+ // ignore
131
+ }
132
+ },
133
+ };
134
+ }
@@ -17,6 +17,11 @@ export interface Point {
17
17
  x: number;
18
18
  y: number;
19
19
  }
20
+ /** A box on screen: its top-left corner and its extent, as a `DOMRect` gives them. */
21
+ export interface ScreenBox extends Size {
22
+ left: number;
23
+ top: number;
24
+ }
20
25
  /**
21
26
  * The box `element` resolves its `left`/`top` against: its containing block, or
22
27
  * the viewport when that is the initial containing block.
@@ -77,3 +82,11 @@ export declare function uprightDockCentre(box: Size, strip: Size, degrees: numbe
77
82
  * where a box exceeds its block and centres on the offending axis.
78
83
  */
79
84
  export declare function clampPointWithinBlock(point: Point, box: Size, block: Size, gutter?: number): Point;
85
+ /**
86
+ * The top-left corner, in viewport coordinates, of a control strip docked
87
+ * beside an upright `tool`: centred below it, or above it when only the space
88
+ * above holds the strip, and slid along the tool's edge to stay inside the
89
+ * block. A strip that fits on neither side takes the roomier one; a strip wider
90
+ * than the block centres on it.
91
+ */
92
+ export declare function uprightDockCorner(tool: ScreenBox, strip: Size, block: ScreenBox, gap: number, gutter?: number): Point;
@@ -126,3 +126,26 @@ export function clampPointWithinBlock(point, box, block, gutter = DEFAULT_CONTAI
126
126
  const offset = clampOffsetWithinBlock({ x: point.x - centre.x, y: point.y - centre.y }, box, block, gutter);
127
127
  return { x: centre.x + offset.x, y: centre.y + offset.y };
128
128
  }
129
+ /**
130
+ * The top-left corner, in viewport coordinates, of a control strip docked
131
+ * beside an upright `tool`: centred below it, or above it when only the space
132
+ * above holds the strip, and slid along the tool's edge to stay inside the
133
+ * block. A strip that fits on neither side takes the roomier one; a strip wider
134
+ * than the block centres on it.
135
+ */
136
+ export function uprightDockCorner(tool, strip, block, gap, gutter = DEFAULT_CONTAINMENT_GUTTER) {
137
+ const top = block.top + gutter;
138
+ const bottom = block.top + block.height - gutter;
139
+ const below = tool.top + tool.height + gap;
140
+ const above = tool.top - gap - strip.height;
141
+ const roomBelow = bottom - below;
142
+ const roomAbove = tool.top - gap - top;
143
+ const y = roomBelow >= strip.height || (roomAbove < strip.height && roomBelow >= roomAbove)
144
+ ? below
145
+ : above;
146
+ const travel = block.width - gutter * 2 - strip.width;
147
+ const x = travel <= 0
148
+ ? block.left + (block.width - strip.width) / 2
149
+ : Math.max(block.left + gutter, Math.min(block.left + gutter + travel, tool.left + (tool.width - strip.width) / 2));
150
+ return { x, y };
151
+ }
@@ -53,6 +53,34 @@ export type OverlayPlacementNudge = keyof typeof OVERLAY_PLACEMENT_NUDGES;
53
53
  export type OverlayPlacementAnnouncement = (typeof OVERLAY_PLACEMENT_NUDGES)[OverlayPlacementNudge]["announceKey"] | "toolkit.announce.rotatedTo";
54
54
  /** The tap controls' rotations, the keyboard's two steps each way. */
55
55
  export declare const OVERLAY_PLACEMENT_ROTATIONS: readonly [number, number, 1, 5];
56
+ /**
57
+ * What the tap controls drive: a step for each arrow, and a turn for a tool
58
+ * that rotates. An `OverlayPlacementController` is one; an upright tool that
59
+ * keeps its own position supplies `nudge` alone.
60
+ */
61
+ export interface OverlayTapTarget {
62
+ nudge: (direction: OverlayPlacementNudge) => void;
63
+ rotateBy?: (degrees: number) => void;
64
+ }
65
+ /** A shrink-and-grow pair on the tap controls, labelled by the tool. */
66
+ export interface OverlaySizeControl {
67
+ shrink: {
68
+ glyph: string;
69
+ label: string;
70
+ };
71
+ grow: {
72
+ glyph: string;
73
+ label: string;
74
+ };
75
+ step: (direction: 1 | -1) => void;
76
+ }
77
+ /**
78
+ * Docks an upright tool's tap controls beside it, as `uprightDockCorner`
79
+ * places them. `OverlayPlacementControls` lays the controls out at the tool's
80
+ * top-left corner, so the offset is taken from there. A strip wider than the
81
+ * block wraps onto more rows, so a phone-width pane does not clip it.
82
+ */
83
+ export declare function dockUprightControls(element: HTMLElement, controls: HTMLElement): void;
56
84
  export interface OverlayPlacementOptions {
57
85
  /** The tool's positioned element, which receives the transform. */
58
86
  getElement: () => HTMLElement | undefined;
@@ -4,7 +4,7 @@
4
4
  * its `style.transform`, so a drag, an arrow key and a tap control always agree
5
5
  * about where the tool is.
6
6
  */
7
- import { clampOffsetOverlappingBlock, resolveContainingBlockRect, rotatedExtent, rotationCentreShift, uprightDockCentre, } from "./overlay-containment.js";
7
+ import { clampOffsetOverlappingBlock, DEFAULT_CONTAINMENT_GUTTER, resolveContainingBlockRect, rotatedExtent, rotationCentreShift, uprightDockCentre, uprightDockCorner, } from "./overlay-containment.js";
8
8
  import { createPointerDragController } from "./pointer-drag.js";
9
9
  import { createPointerGesture } from "./pointer-gesture.js";
10
10
  import { createPointerRotateController } from "./pointer-rotate.js";
@@ -60,8 +60,23 @@ const ARROW_KEYS = {
60
60
  };
61
61
  /** Pixels of the tool kept inside its containing block on each axis. */
62
62
  const MIN_VISIBLE = 100;
63
- /** Space between the tool's bottom edge and its tap controls. */
63
+ /** Space between the tool and its tap controls. */
64
64
  const CONTROLS_GAP = 8;
65
+ /**
66
+ * Docks an upright tool's tap controls beside it, as `uprightDockCorner`
67
+ * places them. `OverlayPlacementControls` lays the controls out at the tool's
68
+ * top-left corner, so the offset is taken from there. A strip wider than the
69
+ * block wraps onto more rows, so a phone-width pane does not clip it.
70
+ */
71
+ export function dockUprightControls(element, controls) {
72
+ const block = resolveContainingBlockRect(element);
73
+ if (!block)
74
+ return;
75
+ controls.style.maxWidth = `${Math.max(0, block.width - DEFAULT_CONTAINMENT_GUTTER * 2)}px`;
76
+ const tool = element.getBoundingClientRect();
77
+ const corner = uprightDockCorner(tool, { width: controls.offsetWidth, height: controls.offsetHeight }, block, CONTROLS_GAP);
78
+ controls.style.transform = `translate(${corner.x - tool.left - element.clientLeft}px, ${corner.y - tool.top - element.clientTop}px)`;
79
+ }
65
80
  export function createOverlayPlacement(options) {
66
81
  let placement = { x: 0, y: 0, rotation: 0 };
67
82
  const sizeOf = (element) => ({
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.4",
4
4
  "type": "module",
5
5
  "description": "Shared runtime + UI utilities for PIE players",
6
6
  "license": "MIT",
@@ -106,9 +106,9 @@
106
106
  "dist"
107
107
  ],
108
108
  "dependencies": {
109
- "@pie-element/shared-utils": "0.1.3",
109
+ "@pie-element/shared-utils": "0.1.4",
110
110
  "@pie-lib/math-rendering-module": "6.0.0",
111
- "dompurify": "^3.4.16",
111
+ "dompurify": "^3.4.17",
112
112
  "semver": "^7.8.5"
113
113
  },
114
114
  "scripts": {
@@ -125,7 +125,7 @@
125
125
  "@biomejs/biome": "^2.5.15",
126
126
  "@happy-dom/global-registrator": "^20.14.5",
127
127
  "@pie-element/shared-math-rendering-mathjax": "0.1.4",
128
- "@playwright/test": "^1.64.0",
128
+ "@playwright/test": "^1.64.1",
129
129
  "@types/semver": "^7.8.0",
130
130
  "es-module-shims": "^2.8.4",
131
131
  "esbuild": "^0.28.2",