@pie-players/pie-players-shared 0.3.45 → 0.3.46

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.
@@ -2,3 +2,4 @@ export { buildAuthoringAllowList, createDefaultItemMarkupSanitizer, resetPurifie
2
2
  export { parseAllowedStyleOrigins, validateExternalStyleUrl, type StyleUrlValidationError, type StyleUrlValidationOk, type StyleUrlValidationOptions, type StyleUrlValidationResult, } from "./validate-style-url.js";
3
3
  export { resetSvgSanitizerForTesting, sanitizeSvgIcon, } from "./sanitize-svg-icon.js";
4
4
  export { wrapOverwideImages } from "./wrap-overwide-images.js";
5
+ export { wrapOverwideTables } from "./wrap-overwide-tables.js";
@@ -2,4 +2,5 @@ export { buildAuthoringAllowList, createDefaultItemMarkupSanitizer, resetPurifie
2
2
  export { parseAllowedStyleOrigins, validateExternalStyleUrl, } from "./validate-style-url.js";
3
3
  export { resetSvgSanitizerForTesting, sanitizeSvgIcon, } from "./sanitize-svg-icon.js";
4
4
  export { wrapOverwideImages } from "./wrap-overwide-images.js";
5
+ export { wrapOverwideTables } from "./wrap-overwide-tables.js";
5
6
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/security/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,uBAAuB,EACvB,gCAAgC,EAChC,uBAAuB,EACvB,kBAAkB,GAGlB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACN,wBAAwB,EACxB,wBAAwB,GAKxB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACN,2BAA2B,EAC3B,eAAe,GACf,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC","sourcesContent":["export {\n\tbuildAuthoringAllowList,\n\tcreateDefaultItemMarkupSanitizer,\n\tresetPurifierForTesting,\n\tsanitizeItemMarkup,\n\ttype ItemMarkupSanitizer,\n\ttype SanitizeItemMarkupOptions,\n} from \"./sanitize-item-markup.js\";\nexport {\n\tparseAllowedStyleOrigins,\n\tvalidateExternalStyleUrl,\n\ttype StyleUrlValidationError,\n\ttype StyleUrlValidationOk,\n\ttype StyleUrlValidationOptions,\n\ttype StyleUrlValidationResult,\n} from \"./validate-style-url.js\";\nexport {\n\tresetSvgSanitizerForTesting,\n\tsanitizeSvgIcon,\n} from \"./sanitize-svg-icon.js\";\nexport { wrapOverwideImages } from \"./wrap-overwide-images.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/security/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,uBAAuB,EACvB,gCAAgC,EAChC,uBAAuB,EACvB,kBAAkB,GAGlB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACN,wBAAwB,EACxB,wBAAwB,GAKxB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACN,2BAA2B,EAC3B,eAAe,GACf,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC","sourcesContent":["export {\n\tbuildAuthoringAllowList,\n\tcreateDefaultItemMarkupSanitizer,\n\tresetPurifierForTesting,\n\tsanitizeItemMarkup,\n\ttype ItemMarkupSanitizer,\n\ttype SanitizeItemMarkupOptions,\n} from \"./sanitize-item-markup.js\";\nexport {\n\tparseAllowedStyleOrigins,\n\tvalidateExternalStyleUrl,\n\ttype StyleUrlValidationError,\n\ttype StyleUrlValidationOk,\n\ttype StyleUrlValidationOptions,\n\ttype StyleUrlValidationResult,\n} from \"./validate-style-url.js\";\nexport {\n\tresetSvgSanitizerForTesting,\n\tsanitizeSvgIcon,\n} from \"./sanitize-svg-icon.js\";\nexport { wrapOverwideImages } from \"./wrap-overwide-images.js\";\nexport { wrapOverwideTables } from \"./wrap-overwide-tables.js\";\n"]}
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import DOMPurify from "dompurify";
11
11
  import { wrapOverwideImages } from "./wrap-overwide-images.js";
12
+ import { wrapOverwideTables } from "./wrap-overwide-tables.js";
12
13
  // Attributes every PIE element / wrapper is allowed to carry.
13
14
  const BASE_ALLOWED_ATTRS = [
14
15
  "slot",
@@ -140,7 +141,9 @@ export function sanitizeItemMarkup(markup, options = {}) {
140
141
  // PIE-94: wrap overwide authored images in a horizontal-scroll container
141
142
  // so they don't get clipped by ancestor `overflow-x: hidden` regions in
142
143
  // the section player (and match WCAG 1.4.10 Reflow at 400% zoom).
143
- return wrapOverwideImages(sanitized);
144
+ // Tables get the same treatment so wide data grids reflow into a
145
+ // scrollable region instead of forcing the page itself to scroll.
146
+ return wrapOverwideTables(wrapOverwideImages(sanitized));
144
147
  }
145
148
  /**
146
149
  * Build the default `ItemMarkupSanitizer` used by the players. The returned
@@ -1 +1 @@
1
- {"version":3,"file":"sanitize-item-markup.js","sourceRoot":"","sources":["../../src/security/sanitize-item-markup.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Default sanitizer for PIE item / passage markup.\n *\n * Used by `PieItemPlayer.svelte` to strip scripts, event-handler attributes,\n * and unknown tags before injecting authored markup via `{@html}`. Hosts can\n * opt out with the `trust-markup` attribute on the `<pie-*-player>` element,\n * or supply their own sanitizer function if they need a stricter / looser\n * allow-list.\n */\n\nimport DOMPurify from \"dompurify\";\n\nimport { wrapOverwideImages } from \"./wrap-overwide-images.js\";\n\nexport type ItemMarkupSanitizer = (markup: string) => string;\n\nexport interface SanitizeItemMarkupOptions {\n\t/**\n\t * Extra custom-element tag names that should survive sanitization in\n\t * addition to the default `pie-*` allow-list. Useful for authoring-mode\n\t * tags that rewrite to `pie-*-config` or host-registered extensions.\n\t */\n\tallowedCustomElements?: string[];\n}\n\n// Attributes every PIE element / wrapper is allowed to carry.\nconst BASE_ALLOWED_ATTRS = [\n\t\"slot\",\n\t\"role\",\n\t\"tabindex\",\n\t\"id\",\n\t\"class\",\n\t\"style\",\n\t\"href\",\n\t\"src\",\n\t\"alt\",\n\t\"title\",\n\t\"hidden\",\n\t\"disabled\",\n\t\"lang\",\n\t\"dir\",\n];\n\nconst BASE_URI_SAFE_ATTRS = [\"pie-id\"];\n\nconst FORBIDDEN_TAGS = [\n\t\"script\",\n\t\"iframe\",\n\t\"object\",\n\t\"embed\",\n\t\"base\",\n\t\"form\",\n\t\"meta\",\n\t\"link\",\n\t// <foreignObject> inside an <svg> is a well-known escape hatch back\n\t// into HTML context; match the SVG-icon sanitizer and forbid it here\n\t// so both sanitizers agree on the surface.\n\t\"foreignobject\",\n];\n\n// DOMPurify already strips `on*` handlers via its default block-list;\n// these entries guarantee they stay stripped even if a consumer tweaks\n// defaults, and they cover the common SVG / math sinks.\nconst FORBIDDEN_ATTRS = [\n\t\"onerror\",\n\t\"onload\",\n\t\"onclick\",\n\t\"onmouseover\",\n\t\"onmouseout\",\n\t\"onmouseenter\",\n\t\"onmouseleave\",\n\t\"onfocus\",\n\t\"onblur\",\n\t\"onkeydown\",\n\t\"onkeyup\",\n\t\"onkeypress\",\n\t\"onsubmit\",\n\t\"onchange\",\n\t\"onbeforeunload\",\n\t\"formaction\",\n\t\"xlink:href\",\n];\n\n// Any tag that looks like a custom element (contains a hyphen) is permitted\n// provided it starts with `pie-` or is explicitly named in\n// `allowedCustomElements`. This intentionally keeps third-party unknown\n// custom elements out unless the host opts in.\nconst PIE_CUSTOM_ELEMENT_REGEX = /^pie-[a-z0-9-]+$/i;\n\n// Attribute names that custom elements are allowed to declare. We stay\n// permissive for the PIE element contract (`model-*`, `session-*`, ...) and\n// the standard `data-*` / `aria-*` families.\nconst CUSTOM_ELEMENT_ATTR_REGEX =\n\t/^(id|class|style|slot|role|tabindex|hidden|disabled|lang|dir|data-[\\w-]+|aria-[\\w-]+|pie-[\\w-]+|model-[\\w-]+|session-[\\w-]+|config-[\\w-]+|context-[\\w-]+)$/i;\n\ninterface DOMPurifyInstance {\n\tsanitize: (\n\t\tsource: string,\n\t\tconfig?: Record<string, unknown>,\n\t) => string | Node | DocumentFragment;\n}\n\nlet purifierInstance: DOMPurifyInstance | null = null;\n\nfunction resolvePurifier(): DOMPurifyInstance | null {\n\tif (purifierInstance) return purifierInstance;\n\tif (typeof window === \"undefined\" || !window.document) return null;\n\t// DOMPurify's default export is both the instance and the factory.\n\t// Calling it with a window binds the instance to that document.\n\tconst factory = DOMPurify as unknown as (\n\t\twin: Window & typeof globalThis,\n\t) => DOMPurifyInstance;\n\tpurifierInstance =\n\t\ttypeof factory === \"function\"\n\t\t\t? factory(window as Window & typeof globalThis)\n\t\t\t: (DOMPurify as unknown as DOMPurifyInstance);\n\treturn purifierInstance;\n}\n\n/**\n * Sanitize raw item/passage markup before it is injected into the DOM.\n *\n * - Strips `<script>`, event-handler attributes, unknown protocols and\n * a standard set of dangerous tags (`iframe`, `object`, `embed`, `base`,\n * `form`, `meta`, `link`).\n * - Preserves PIE custom elements (`pie-*`) and any extra tags listed in\n * `allowedCustomElements`.\n * - During SSR (no `window`) returns an empty string so untrusted markup\n * never reaches the prerender output; the live renderer will re-run the\n * sanitizer on hydrate.\n */\nexport function sanitizeItemMarkup(\n\tmarkup: string,\n\toptions: SanitizeItemMarkupOptions = {},\n): string {\n\tif (!markup) return \"\";\n\tconst purifier = resolvePurifier();\n\tif (!purifier) return \"\";\n\n\tconst allowedCustomElements = (options.allowedCustomElements ?? []).map(\n\t\t(name) => name.toLowerCase(),\n\t);\n\tconst explicitCustomElementSet = new Set(allowedCustomElements);\n\n\tconst result = purifier.sanitize(markup, {\n\t\tADD_TAGS: allowedCustomElements,\n\t\tADD_ATTR: BASE_ALLOWED_ATTRS,\n\t\tADD_URI_SAFE_ATTR: BASE_URI_SAFE_ATTRS,\n\t\tFORBID_TAGS: FORBIDDEN_TAGS,\n\t\tFORBID_ATTR: FORBIDDEN_ATTRS,\n\t\tALLOW_UNKNOWN_PROTOCOLS: false,\n\t\tSANITIZE_DOM: true,\n\t\t// pie-item contract compatibility: PIE models are matched to DOM\n\t\t// elements via strict `id` equality (see `updateSinglePieElement`\n\t\t// in players-shared/src/pie/updates.ts). `SANITIZE_NAMED_PROPS`\n\t\t// would prefix every `id`/`name` with `user-content-`, which silently\n\t\t// breaks model lookup for every item. `SANITIZE_DOM: true` above\n\t\t// still provides the core DOM-clobbering defenses we rely on.\n\t\tSANITIZE_NAMED_PROPS: false,\n\t\tWHOLE_DOCUMENT: false,\n\t\tALLOW_DATA_ATTR: true,\n\t\tALLOW_ARIA_ATTR: true,\n\t\tCUSTOM_ELEMENT_HANDLING: {\n\t\t\ttagNameCheck: (tagName: string) => {\n\t\t\t\tconst lower = tagName.toLowerCase();\n\t\t\t\treturn (\n\t\t\t\t\tPIE_CUSTOM_ELEMENT_REGEX.test(lower) ||\n\t\t\t\t\texplicitCustomElementSet.has(lower)\n\t\t\t\t);\n\t\t\t},\n\t\t\tattributeNameCheck: (attrName: string) =>\n\t\t\t\tCUSTOM_ELEMENT_ATTR_REGEX.test(attrName),\n\t\t\tallowCustomizedBuiltInElements: false,\n\t\t},\n\t\tRETURN_TRUSTED_TYPE: false,\n\t});\n\n\tconst sanitized =\n\t\ttypeof result === \"string\" ? result : String(result ?? \"\");\n\t// PIE-94: wrap overwide authored images in a horizontal-scroll container\n\t// so they don't get clipped by ancestor `overflow-x: hidden` regions in\n\t// the section player (and match WCAG 1.4.10 Reflow at 400% zoom).\n\treturn wrapOverwideImages(sanitized);\n}\n\n/**\n * Build the default `ItemMarkupSanitizer` used by the players. The returned\n * function is stable for a given set of allowed custom elements so callers\n * can safely use reference equality when deciding whether to re-sanitize.\n */\nexport function createDefaultItemMarkupSanitizer(\n\toptions: SanitizeItemMarkupOptions = {},\n): ItemMarkupSanitizer {\n\tconst { allowedCustomElements } = options;\n\treturn (markup: string) =>\n\t\tsanitizeItemMarkup(markup, { allowedCustomElements });\n}\n\n/**\n * Derive the authoring-mode allow-list (`pie-*-config`) from a set of PIE\n * element tag names. Used by `transformMarkupForAuthoring` so the sanitizer\n * keeps the rewritten `-config` tags instead of stripping them.\n */\nexport function buildAuthoringAllowList(\n\telementTagNames: Iterable<string>,\n): string[] {\n\tconst out = new Set<string>();\n\tfor (const tag of elementTagNames) {\n\t\tif (!tag) continue;\n\t\tconst lower = tag.toLowerCase();\n\t\tout.add(lower);\n\t\tout.add(`${lower}-config`);\n\t}\n\treturn [...out];\n}\n\n/** Reset the memoised DOMPurify instance. Only intended for tests. */\nexport function resetPurifierForTesting() {\n\tpurifierInstance = null;\n}\n"]}
1
+ {"version":3,"file":"sanitize-item-markup.js","sourceRoot":"","sources":["../../src/security/sanitize-item-markup.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Default sanitizer for PIE item / passage markup.\n *\n * Used by `PieItemPlayer.svelte` to strip scripts, event-handler attributes,\n * and unknown tags before injecting authored markup via `{@html}`. Hosts can\n * opt out with the `trust-markup` attribute on the `<pie-*-player>` element,\n * or supply their own sanitizer function if they need a stricter / looser\n * allow-list.\n */\n\nimport DOMPurify from \"dompurify\";\n\nimport { wrapOverwideImages } from \"./wrap-overwide-images.js\";\nimport { wrapOverwideTables } from \"./wrap-overwide-tables.js\";\n\nexport type ItemMarkupSanitizer = (markup: string) => string;\n\nexport interface SanitizeItemMarkupOptions {\n\t/**\n\t * Extra custom-element tag names that should survive sanitization in\n\t * addition to the default `pie-*` allow-list. Useful for authoring-mode\n\t * tags that rewrite to `pie-*-config` or host-registered extensions.\n\t */\n\tallowedCustomElements?: string[];\n}\n\n// Attributes every PIE element / wrapper is allowed to carry.\nconst BASE_ALLOWED_ATTRS = [\n\t\"slot\",\n\t\"role\",\n\t\"tabindex\",\n\t\"id\",\n\t\"class\",\n\t\"style\",\n\t\"href\",\n\t\"src\",\n\t\"alt\",\n\t\"title\",\n\t\"hidden\",\n\t\"disabled\",\n\t\"lang\",\n\t\"dir\",\n];\n\nconst BASE_URI_SAFE_ATTRS = [\"pie-id\"];\n\nconst FORBIDDEN_TAGS = [\n\t\"script\",\n\t\"iframe\",\n\t\"object\",\n\t\"embed\",\n\t\"base\",\n\t\"form\",\n\t\"meta\",\n\t\"link\",\n\t// <foreignObject> inside an <svg> is a well-known escape hatch back\n\t// into HTML context; match the SVG-icon sanitizer and forbid it here\n\t// so both sanitizers agree on the surface.\n\t\"foreignobject\",\n];\n\n// DOMPurify already strips `on*` handlers via its default block-list;\n// these entries guarantee they stay stripped even if a consumer tweaks\n// defaults, and they cover the common SVG / math sinks.\nconst FORBIDDEN_ATTRS = [\n\t\"onerror\",\n\t\"onload\",\n\t\"onclick\",\n\t\"onmouseover\",\n\t\"onmouseout\",\n\t\"onmouseenter\",\n\t\"onmouseleave\",\n\t\"onfocus\",\n\t\"onblur\",\n\t\"onkeydown\",\n\t\"onkeyup\",\n\t\"onkeypress\",\n\t\"onsubmit\",\n\t\"onchange\",\n\t\"onbeforeunload\",\n\t\"formaction\",\n\t\"xlink:href\",\n];\n\n// Any tag that looks like a custom element (contains a hyphen) is permitted\n// provided it starts with `pie-` or is explicitly named in\n// `allowedCustomElements`. This intentionally keeps third-party unknown\n// custom elements out unless the host opts in.\nconst PIE_CUSTOM_ELEMENT_REGEX = /^pie-[a-z0-9-]+$/i;\n\n// Attribute names that custom elements are allowed to declare. We stay\n// permissive for the PIE element contract (`model-*`, `session-*`, ...) and\n// the standard `data-*` / `aria-*` families.\nconst CUSTOM_ELEMENT_ATTR_REGEX =\n\t/^(id|class|style|slot|role|tabindex|hidden|disabled|lang|dir|data-[\\w-]+|aria-[\\w-]+|pie-[\\w-]+|model-[\\w-]+|session-[\\w-]+|config-[\\w-]+|context-[\\w-]+)$/i;\n\ninterface DOMPurifyInstance {\n\tsanitize: (\n\t\tsource: string,\n\t\tconfig?: Record<string, unknown>,\n\t) => string | Node | DocumentFragment;\n}\n\nlet purifierInstance: DOMPurifyInstance | null = null;\n\nfunction resolvePurifier(): DOMPurifyInstance | null {\n\tif (purifierInstance) return purifierInstance;\n\tif (typeof window === \"undefined\" || !window.document) return null;\n\t// DOMPurify's default export is both the instance and the factory.\n\t// Calling it with a window binds the instance to that document.\n\tconst factory = DOMPurify as unknown as (\n\t\twin: Window & typeof globalThis,\n\t) => DOMPurifyInstance;\n\tpurifierInstance =\n\t\ttypeof factory === \"function\"\n\t\t\t? factory(window as Window & typeof globalThis)\n\t\t\t: (DOMPurify as unknown as DOMPurifyInstance);\n\treturn purifierInstance;\n}\n\n/**\n * Sanitize raw item/passage markup before it is injected into the DOM.\n *\n * - Strips `<script>`, event-handler attributes, unknown protocols and\n * a standard set of dangerous tags (`iframe`, `object`, `embed`, `base`,\n * `form`, `meta`, `link`).\n * - Preserves PIE custom elements (`pie-*`) and any extra tags listed in\n * `allowedCustomElements`.\n * - During SSR (no `window`) returns an empty string so untrusted markup\n * never reaches the prerender output; the live renderer will re-run the\n * sanitizer on hydrate.\n */\nexport function sanitizeItemMarkup(\n\tmarkup: string,\n\toptions: SanitizeItemMarkupOptions = {},\n): string {\n\tif (!markup) return \"\";\n\tconst purifier = resolvePurifier();\n\tif (!purifier) return \"\";\n\n\tconst allowedCustomElements = (options.allowedCustomElements ?? []).map(\n\t\t(name) => name.toLowerCase(),\n\t);\n\tconst explicitCustomElementSet = new Set(allowedCustomElements);\n\n\tconst result = purifier.sanitize(markup, {\n\t\tADD_TAGS: allowedCustomElements,\n\t\tADD_ATTR: BASE_ALLOWED_ATTRS,\n\t\tADD_URI_SAFE_ATTR: BASE_URI_SAFE_ATTRS,\n\t\tFORBID_TAGS: FORBIDDEN_TAGS,\n\t\tFORBID_ATTR: FORBIDDEN_ATTRS,\n\t\tALLOW_UNKNOWN_PROTOCOLS: false,\n\t\tSANITIZE_DOM: true,\n\t\t// pie-item contract compatibility: PIE models are matched to DOM\n\t\t// elements via strict `id` equality (see `updateSinglePieElement`\n\t\t// in players-shared/src/pie/updates.ts). `SANITIZE_NAMED_PROPS`\n\t\t// would prefix every `id`/`name` with `user-content-`, which silently\n\t\t// breaks model lookup for every item. `SANITIZE_DOM: true` above\n\t\t// still provides the core DOM-clobbering defenses we rely on.\n\t\tSANITIZE_NAMED_PROPS: false,\n\t\tWHOLE_DOCUMENT: false,\n\t\tALLOW_DATA_ATTR: true,\n\t\tALLOW_ARIA_ATTR: true,\n\t\tCUSTOM_ELEMENT_HANDLING: {\n\t\t\ttagNameCheck: (tagName: string) => {\n\t\t\t\tconst lower = tagName.toLowerCase();\n\t\t\t\treturn (\n\t\t\t\t\tPIE_CUSTOM_ELEMENT_REGEX.test(lower) ||\n\t\t\t\t\texplicitCustomElementSet.has(lower)\n\t\t\t\t);\n\t\t\t},\n\t\t\tattributeNameCheck: (attrName: string) =>\n\t\t\t\tCUSTOM_ELEMENT_ATTR_REGEX.test(attrName),\n\t\t\tallowCustomizedBuiltInElements: false,\n\t\t},\n\t\tRETURN_TRUSTED_TYPE: false,\n\t});\n\n\tconst sanitized =\n\t\ttypeof result === \"string\" ? result : String(result ?? \"\");\n\t// PIE-94: wrap overwide authored images in a horizontal-scroll container\n\t// so they don't get clipped by ancestor `overflow-x: hidden` regions in\n\t// the section player (and match WCAG 1.4.10 Reflow at 400% zoom).\n\t// Tables get the same treatment so wide data grids reflow into a\n\t// scrollable region instead of forcing the page itself to scroll.\n\treturn wrapOverwideTables(wrapOverwideImages(sanitized));\n}\n\n/**\n * Build the default `ItemMarkupSanitizer` used by the players. The returned\n * function is stable for a given set of allowed custom elements so callers\n * can safely use reference equality when deciding whether to re-sanitize.\n */\nexport function createDefaultItemMarkupSanitizer(\n\toptions: SanitizeItemMarkupOptions = {},\n): ItemMarkupSanitizer {\n\tconst { allowedCustomElements } = options;\n\treturn (markup: string) =>\n\t\tsanitizeItemMarkup(markup, { allowedCustomElements });\n}\n\n/**\n * Derive the authoring-mode allow-list (`pie-*-config`) from a set of PIE\n * element tag names. Used by `transformMarkupForAuthoring` so the sanitizer\n * keeps the rewritten `-config` tags instead of stripping them.\n */\nexport function buildAuthoringAllowList(\n\telementTagNames: Iterable<string>,\n): string[] {\n\tconst out = new Set<string>();\n\tfor (const tag of elementTagNames) {\n\t\tif (!tag) continue;\n\t\tconst lower = tag.toLowerCase();\n\t\tout.add(lower);\n\t\tout.add(`${lower}-config`);\n\t}\n\treturn [...out];\n}\n\n/** Reset the memoised DOMPurify instance. Only intended for tests. */\nexport function resetPurifierForTesting() {\n\tpurifierInstance = null;\n}\n"]}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Wraps authored `<table>` elements with a horizontally scrollable container so
3
+ * tables that are wider than their column surface a scrollbar instead of being
4
+ * clipped by ancestor `overflow-x: hidden` regions.
5
+ *
6
+ * The wrapper is rendered as
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
9
+ * `overflow-x: auto` so narrow tables stay visually unchanged: a scrollbar only
10
+ * appears when the table's intrinsic width exceeds the wrapper's available
11
+ * space (including at higher browser-zoom levels — WCAG 1.4.10 Reflow at 400%
12
+ * zoom is the same driver as for `wrapOverwideImages`).
13
+ *
14
+ * This helper runs as a post-sanitization step inside `sanitizeItemMarkup`, so
15
+ * every host that renders authored markup through the shared
16
+ * `pie-item-player` (including the section player) benefits uniformly.
17
+ */
18
+ export declare function wrapOverwideTables(markup: string): string;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Wraps authored `<table>` elements with a horizontally scrollable container so
3
+ * tables that are wider than their column surface a scrollbar instead of being
4
+ * clipped by ancestor `overflow-x: hidden` regions.
5
+ *
6
+ * The wrapper is rendered as
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
9
+ * `overflow-x: auto` so narrow tables stay visually unchanged: a scrollbar only
10
+ * appears when the table's intrinsic width exceeds the wrapper's available
11
+ * space (including at higher browser-zoom levels — WCAG 1.4.10 Reflow at 400%
12
+ * zoom is the same driver as for `wrapOverwideImages`).
13
+ *
14
+ * This helper runs as a post-sanitization step inside `sanitizeItemMarkup`, so
15
+ * every host that renders authored markup through the shared
16
+ * `pie-item-player` (including the section player) benefits uniformly.
17
+ */
18
+ const PIE_CUSTOM_ELEMENT_TAG_REGEX = /^pie-/i;
19
+ const SCROLL_WRAPPER_CLASS = "pie-table-scroll";
20
+ function isInsidePieCustomElement(table, root) {
21
+ let ancestor = table.parentElement;
22
+ while (ancestor && ancestor !== root) {
23
+ if (PIE_CUSTOM_ELEMENT_TAG_REGEX.test(ancestor.tagName)) {
24
+ return true;
25
+ }
26
+ ancestor = ancestor.parentElement;
27
+ }
28
+ return false;
29
+ }
30
+ function buildAriaLabel(table) {
31
+ // Authors commonly label tables via <caption>, aria-label, or aria-labelledby.
32
+ // Prefer the most explicit signal and fall back to the generic label so
33
+ // every wrapper still announces itself as a region.
34
+ const ariaLabel = table.getAttribute("aria-label");
35
+ if (ariaLabel?.trim()) {
36
+ return `Scrollable table: ${ariaLabel.trim()}`;
37
+ }
38
+ const labelledBy = table.getAttribute("aria-labelledby");
39
+ if (labelledBy?.trim()) {
40
+ const ownerDocument = table.ownerDocument;
41
+ const ids = labelledBy.trim().split(/\s+/);
42
+ const labels = [];
43
+ for (const id of ids) {
44
+ const labelEl = ownerDocument?.getElementById(id);
45
+ const text = labelEl?.textContent?.trim();
46
+ if (text)
47
+ labels.push(text);
48
+ }
49
+ if (labels.length > 0) {
50
+ return `Scrollable table: ${labels.join(" ")}`;
51
+ }
52
+ }
53
+ const caption = table.querySelector("caption");
54
+ const captionText = caption?.textContent?.trim();
55
+ if (captionText) {
56
+ return `Scrollable table: ${captionText}`;
57
+ }
58
+ return "Scrollable table";
59
+ }
60
+ export function wrapOverwideTables(markup) {
61
+ if (!markup)
62
+ return "";
63
+ // Fast path: avoid the DOM round-trip entirely when the markup carries no
64
+ // tables. Keeps the sanitize pipeline cheap for the common case.
65
+ if (!/<table\b/i.test(markup))
66
+ return markup;
67
+ if (typeof window === "undefined" || !window.document)
68
+ return markup;
69
+ const ParserCtor = typeof DOMParser !== "undefined"
70
+ ? DOMParser
71
+ : window.DOMParser;
72
+ if (!ParserCtor)
73
+ return markup;
74
+ const doc = new ParserCtor().parseFromString(`<!DOCTYPE html><html><body>${markup}</body></html>`, "text/html");
75
+ const body = doc.body;
76
+ if (!body)
77
+ return markup;
78
+ const tables = Array.from(body.querySelectorAll("table"));
79
+ if (tables.length === 0)
80
+ return markup;
81
+ let mutated = false;
82
+ for (const table of tables) {
83
+ const parent = table.parentElement;
84
+ if (!parent)
85
+ continue;
86
+ // Idempotency — already wrapped.
87
+ if (parent.classList &&
88
+ parent.classList.contains(SCROLL_WRAPPER_CLASS)) {
89
+ continue;
90
+ }
91
+ // Leave PIE custom-element internals alone.
92
+ if (isInsidePieCustomElement(table, body))
93
+ continue;
94
+ const wrapper = doc.createElement("div");
95
+ wrapper.className = SCROLL_WRAPPER_CLASS;
96
+ wrapper.setAttribute("tabindex", "0");
97
+ wrapper.setAttribute("role", "region");
98
+ wrapper.setAttribute("aria-label", buildAriaLabel(table));
99
+ parent.insertBefore(wrapper, table);
100
+ wrapper.appendChild(table);
101
+ mutated = true;
102
+ }
103
+ return mutated ? body.innerHTML : markup;
104
+ }
105
+ //# sourceMappingURL=wrap-overwide-tables.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wrap-overwide-tables.js","sourceRoot":"","sources":["../../src/security/wrap-overwide-tables.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Wraps authored `<table>` elements with a horizontally scrollable container so\n * tables that are wider than their column surface a scrollbar instead of being\n * clipped by ancestor `overflow-x: hidden` regions.\n *\n * The wrapper is rendered as\n * `<div class=\"pie-table-scroll\" tabindex=\"0\" role=\"region\" aria-label=\"...\">`\n * and receives the accompanying CSS from `@pie-players/pie-theme`. The CSS uses\n * `overflow-x: auto` so narrow tables stay visually unchanged: a scrollbar only\n * appears when the table's intrinsic width exceeds the wrapper's available\n * space (including at higher browser-zoom levels — WCAG 1.4.10 Reflow at 400%\n * zoom is the same driver as for `wrapOverwideImages`).\n *\n * This helper runs as a post-sanitization step inside `sanitizeItemMarkup`, so\n * every host that renders authored markup through the shared\n * `pie-item-player` (including the section player) benefits uniformly.\n */\n\nconst PIE_CUSTOM_ELEMENT_TAG_REGEX = /^pie-/i;\nconst SCROLL_WRAPPER_CLASS = \"pie-table-scroll\";\n\nfunction isInsidePieCustomElement(table: Element, root: Element): boolean {\n\tlet ancestor: Element | null = table.parentElement;\n\twhile (ancestor && ancestor !== root) {\n\t\tif (PIE_CUSTOM_ELEMENT_TAG_REGEX.test(ancestor.tagName)) {\n\t\t\treturn true;\n\t\t}\n\t\tancestor = ancestor.parentElement;\n\t}\n\treturn false;\n}\n\nfunction buildAriaLabel(table: Element): string {\n\t// Authors commonly label tables via <caption>, aria-label, or aria-labelledby.\n\t// Prefer the most explicit signal and fall back to the generic label so\n\t// every wrapper still announces itself as a region.\n\tconst ariaLabel = table.getAttribute(\"aria-label\");\n\tif (ariaLabel?.trim()) {\n\t\treturn `Scrollable table: ${ariaLabel.trim()}`;\n\t}\n\tconst labelledBy = table.getAttribute(\"aria-labelledby\");\n\tif (labelledBy?.trim()) {\n\t\tconst ownerDocument = table.ownerDocument;\n\t\tconst ids = labelledBy.trim().split(/\\s+/);\n\t\tconst labels: string[] = [];\n\t\tfor (const id of ids) {\n\t\t\tconst labelEl = ownerDocument?.getElementById(id);\n\t\t\tconst text = labelEl?.textContent?.trim();\n\t\t\tif (text) labels.push(text);\n\t\t}\n\t\tif (labels.length > 0) {\n\t\t\treturn `Scrollable table: ${labels.join(\" \")}`;\n\t\t}\n\t}\n\tconst caption = table.querySelector(\"caption\");\n\tconst captionText = caption?.textContent?.trim();\n\tif (captionText) {\n\t\treturn `Scrollable table: ${captionText}`;\n\t}\n\treturn \"Scrollable table\";\n}\n\nexport function wrapOverwideTables(markup: string): string {\n\tif (!markup) return \"\";\n\n\t// Fast path: avoid the DOM round-trip entirely when the markup carries no\n\t// tables. Keeps the sanitize pipeline cheap for the common case.\n\tif (!/<table\\b/i.test(markup)) return markup;\n\n\tif (typeof window === \"undefined\" || !window.document) return markup;\n\n\tconst ParserCtor =\n\t\ttypeof DOMParser !== \"undefined\"\n\t\t\t? DOMParser\n\t\t\t: (window as unknown as { DOMParser?: typeof DOMParser }).DOMParser;\n\tif (!ParserCtor) return markup;\n\n\tconst doc = new ParserCtor().parseFromString(\n\t\t`<!DOCTYPE html><html><body>${markup}</body></html>`,\n\t\t\"text/html\",\n\t);\n\tconst body = doc.body;\n\tif (!body) return markup;\n\n\tconst tables = Array.from(body.querySelectorAll(\"table\"));\n\tif (tables.length === 0) return markup;\n\n\tlet mutated = false;\n\tfor (const table of tables) {\n\t\tconst parent = table.parentElement;\n\t\tif (!parent) continue;\n\n\t\t// Idempotency — already wrapped.\n\t\tif (\n\t\t\tparent.classList &&\n\t\t\tparent.classList.contains(SCROLL_WRAPPER_CLASS)\n\t\t) {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Leave PIE custom-element internals alone.\n\t\tif (isInsidePieCustomElement(table, body)) continue;\n\n\t\tconst wrapper = doc.createElement(\"div\");\n\t\twrapper.className = SCROLL_WRAPPER_CLASS;\n\t\twrapper.setAttribute(\"tabindex\", \"0\");\n\t\twrapper.setAttribute(\"role\", \"region\");\n\t\twrapper.setAttribute(\"aria-label\", buildAriaLabel(table));\n\n\t\tparent.insertBefore(wrapper, table);\n\t\twrapper.appendChild(table);\n\t\tmutated = true;\n\t}\n\n\treturn mutated ? body.innerHTML : markup;\n}\n"]}
@@ -1,6 +1,21 @@
1
1
  type FocusTrapOptions = {
2
2
  initialFocus?: HTMLElement | null;
3
3
  onEscape?: () => void;
4
+ /**
5
+ * When true (default), Tab from the last focusable wraps to the first and
6
+ * Shift+Tab from the first wraps to the last — appropriate for modal dialogs
7
+ * and popovers. When false, tab boundaries fall through to the browser's
8
+ * natural tab order, so users can step out of the container in either
9
+ * direction. Escape handling is unaffected.
10
+ */
11
+ wrap?: boolean;
12
+ /**
13
+ * Fires when Tab attempts to leave the trap from a boundary. Only invoked
14
+ * when `wrap` is false. The handler may call `event.preventDefault()` and
15
+ * move focus to a custom destination; otherwise the browser's natural tab
16
+ * order applies.
17
+ */
18
+ onTabExit?: (direction: "forward" | "backward", event: KeyboardEvent) => void;
4
19
  };
5
20
  /**
6
21
  * Focus trap helper for floating dialogs/panels.
@@ -1,4 +1,4 @@
1
- import { FOCUSABLE_SELECTOR, isProgrammaticFocusTarget } from "./first-focusable.js";
1
+ import { FOCUSABLE_SELECTOR, isProgrammaticFocusTarget, } from "./first-focusable.js";
2
2
  function getFocusableElements(container) {
3
3
  return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => isProgrammaticFocusTarget(el));
4
4
  }
@@ -26,7 +26,10 @@ function focusInitialTarget(container, initialFocus) {
26
26
  * and restores prior focus when the trap is removed.
27
27
  */
28
28
  export function createFocusTrap(container, options = {}) {
29
- const prev = typeof document !== "undefined" ? document.activeElement : null;
29
+ const prev = typeof document !== "undefined"
30
+ ? document.activeElement
31
+ : null;
32
+ const wrap = options.wrap ?? true;
30
33
  const onKeydown = (event) => {
31
34
  if (event.key === "Escape") {
32
35
  options.onEscape?.();
@@ -36,6 +39,8 @@ export function createFocusTrap(container, options = {}) {
36
39
  return;
37
40
  const focusable = getFocusableElements(container);
38
41
  if (!focusable.length) {
42
+ if (!wrap)
43
+ return;
39
44
  event.preventDefault();
40
45
  container.focus?.();
41
46
  return;
@@ -44,14 +49,24 @@ export function createFocusTrap(container, options = {}) {
44
49
  const currentIndex = focusable.indexOf(current || focusable[0]);
45
50
  if (event.shiftKey) {
46
51
  if (currentIndex <= 0) {
47
- event.preventDefault();
48
- focusable[focusable.length - 1].focus();
52
+ if (wrap) {
53
+ event.preventDefault();
54
+ focusable[focusable.length - 1].focus();
55
+ }
56
+ else {
57
+ options.onTabExit?.("backward", event);
58
+ }
49
59
  }
50
60
  return;
51
61
  }
52
62
  if (currentIndex === focusable.length - 1) {
53
- event.preventDefault();
54
- focusable[0].focus();
63
+ if (wrap) {
64
+ event.preventDefault();
65
+ focusable[0].focus();
66
+ }
67
+ else {
68
+ options.onTabExit?.("forward", event);
69
+ }
55
70
  }
56
71
  };
57
72
  queueMicrotask(() => focusInitialTarget(container, options.initialFocus));
@@ -1 +1 @@
1
- {"version":3,"file":"focus-trap.js","sourceRoot":"","sources":["../../src/ui/focus-trap.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AAOrF,SAAS,oBAAoB,CAAC,SAAsB;IACnD,OAAO,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,gBAAgB,CAAc,kBAAkB,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAC5F,yBAAyB,CAAC,EAAE,CAAC,CAC7B,CAAC;AACH,CAAC;AAED,SAAS,kBAAkB,CAAC,SAAsB,EAAE,YAAiC;IACpF,IAAI,CAAC;QACJ,IAAI,YAAY,IAAI,SAAS,CAAC,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC;YACtD,YAAY,CAAC,KAAK,EAAE,CAAC;YACrB,OAAO;QACR,CAAC;QACD,MAAM,SAAS,GAAG,oBAAoB,CAAC,SAAS,CAAC,CAAC;QAClD,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC1B,SAAS,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC;YACrB,OAAO;QACR,CAAC;QACD,SAAS,CAAC,KAAK,EAAE,EAAE,CAAC;IACrB,CAAC;IAAC,MAAM,CAAC;QACR,SAAS;IACV,CAAC;AACF,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,SAAsB,EAAE,UAA4B,EAAE;IACrF,MAAM,IAAI,GACT,OAAO,QAAQ,KAAK,WAAW,CAAC,CAAC,CAAE,QAAQ,CAAC,aAAoC,CAAC,CAAC,CAAC,IAAI,CAAC;IACzF,MAAM,SAAS,GAAG,CAAC,KAAoB,EAAE,EAAE;QAC1C,IAAI,KAAK,CAAC,GAAG,KAAK,QAAQ,EAAE,CAAC;YAC5B,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC;YACrB,OAAO;QACR,CAAC;QACD,IAAI,KAAK,CAAC,GAAG,KAAK,KAAK;YAAE,OAAO;QAEhC,MAAM,SAAS,GAAG,oBAAoB,CAAC,SAAS,CAAC,CAAC;QAClD,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;YACvB,KAAK,CAAC,cAAc,EAAE,CAAC;YACvB,SAAS,CAAC,KAAK,EAAE,EAAE,CAAC;YACpB,OAAO;QACR,CAAC;QAED,MAAM,OAAO,GAAG,QAAQ,CAAC,aAAmC,CAAC;QAC7D,MAAM,YAAY,GAAG,SAAS,CAAC,OAAO,CAAC,OAAO,IAAI,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;QAChE,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;YACpB,IAAI,YAAY,IAAI,CAAC,EAAE,CAAC;gBACvB,KAAK,CAAC,cAAc,EAAE,CAAC;gBACvB,SAAS,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC;YACzC,CAAC;YACD,OAAO;QACR,CAAC;QACD,IAAI,YAAY,KAAK,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC3C,KAAK,CAAC,cAAc,EAAE,CAAC;YACvB,SAAS,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC;QACtB,CAAC;IACF,CAAC,CAAC;IAEF,cAAc,CAAC,GAAG,EAAE,CAAC,kBAAkB,CAAC,SAAS,EAAE,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC;IAC1E,SAAS,CAAC,gBAAgB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;IAEjD,OAAO,GAAG,EAAE;QACX,SAAS,CAAC,mBAAmB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;QACpD,IAAI,CAAC;YACJ,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QACjB,CAAC;QAAC,MAAM,CAAC;YACR,SAAS;QACV,CAAC;IACF,CAAC,CAAC;AACH,CAAC","sourcesContent":["import { FOCUSABLE_SELECTOR, isProgrammaticFocusTarget } from \"./first-focusable.js\";\n\ntype FocusTrapOptions = {\n\tinitialFocus?: HTMLElement | null;\n\tonEscape?: () => void;\n};\n\nfunction getFocusableElements(container: HTMLElement): HTMLElement[] {\n\treturn Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((el) =>\n\t\tisProgrammaticFocusTarget(el),\n\t);\n}\n\nfunction focusInitialTarget(container: HTMLElement, initialFocus?: HTMLElement | null): void {\n\ttry {\n\t\tif (initialFocus && container.contains(initialFocus)) {\n\t\t\tinitialFocus.focus();\n\t\t\treturn;\n\t\t}\n\t\tconst focusable = getFocusableElements(container);\n\t\tif (focusable.length > 0) {\n\t\t\tfocusable[0].focus();\n\t\t\treturn;\n\t\t}\n\t\tcontainer.focus?.();\n\t} catch {\n\t\t// ignore\n\t}\n}\n\n/**\n * Focus trap helper for floating dialogs/panels.\n *\n * Keeps tab navigation contained, supports Escape callback,\n * and restores prior focus when the trap is removed.\n */\nexport function createFocusTrap(container: HTMLElement, options: FocusTrapOptions = {}): () => void {\n\tconst prev =\n\t\ttypeof document !== \"undefined\" ? (document.activeElement as HTMLElement | null) : null;\n\tconst onKeydown = (event: KeyboardEvent) => {\n\t\tif (event.key === \"Escape\") {\n\t\t\toptions.onEscape?.();\n\t\t\treturn;\n\t\t}\n\t\tif (event.key !== \"Tab\") return;\n\n\t\tconst focusable = getFocusableElements(container);\n\t\tif (!focusable.length) {\n\t\t\tevent.preventDefault();\n\t\t\tcontainer.focus?.();\n\t\t\treturn;\n\t\t}\n\n\t\tconst current = document.activeElement as HTMLElement | null;\n\t\tconst currentIndex = focusable.indexOf(current || focusable[0]);\n\t\tif (event.shiftKey) {\n\t\t\tif (currentIndex <= 0) {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tfocusable[focusable.length - 1].focus();\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tif (currentIndex === focusable.length - 1) {\n\t\t\tevent.preventDefault();\n\t\t\tfocusable[0].focus();\n\t\t}\n\t};\n\n\tqueueMicrotask(() => focusInitialTarget(container, options.initialFocus));\n\tcontainer.addEventListener(\"keydown\", onKeydown);\n\n\treturn () => {\n\t\tcontainer.removeEventListener(\"keydown\", onKeydown);\n\t\ttry {\n\t\t\tprev?.focus?.();\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t};\n}\n"]}
1
+ {"version":3,"file":"focus-trap.js","sourceRoot":"","sources":["../../src/ui/focus-trap.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n\tFOCUSABLE_SELECTOR,\n\tisProgrammaticFocusTarget,\n} from \"./first-focusable.js\";\n\ntype FocusTrapOptions = {\n\tinitialFocus?: HTMLElement | null;\n\tonEscape?: () => void;\n\t/**\n\t * When true (default), Tab from the last focusable wraps to the first and\n\t * Shift+Tab from the first wraps to the last — appropriate for modal dialogs\n\t * and popovers. When false, tab boundaries fall through to the browser's\n\t * natural tab order, so users can step out of the container in either\n\t * direction. Escape handling is unaffected.\n\t */\n\twrap?: boolean;\n\t/**\n\t * Fires when Tab attempts to leave the trap from a boundary. Only invoked\n\t * when `wrap` is false. The handler may call `event.preventDefault()` and\n\t * move focus to a custom destination; otherwise the browser's natural tab\n\t * order applies.\n\t */\n\tonTabExit?: (direction: \"forward\" | \"backward\", event: KeyboardEvent) => void;\n};\n\nfunction getFocusableElements(container: HTMLElement): HTMLElement[] {\n\treturn Array.from(\n\t\tcontainer.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),\n\t).filter((el) => isProgrammaticFocusTarget(el));\n}\n\nfunction focusInitialTarget(\n\tcontainer: HTMLElement,\n\tinitialFocus?: HTMLElement | null,\n): void {\n\ttry {\n\t\tif (initialFocus && container.contains(initialFocus)) {\n\t\t\tinitialFocus.focus();\n\t\t\treturn;\n\t\t}\n\t\tconst focusable = getFocusableElements(container);\n\t\tif (focusable.length > 0) {\n\t\t\tfocusable[0].focus();\n\t\t\treturn;\n\t\t}\n\t\tcontainer.focus?.();\n\t} catch {\n\t\t// ignore\n\t}\n}\n\n/**\n * Focus trap helper for floating dialogs/panels.\n *\n * Keeps tab navigation contained, supports Escape callback,\n * and restores prior focus when the trap is removed.\n */\nexport function createFocusTrap(\n\tcontainer: HTMLElement,\n\toptions: FocusTrapOptions = {},\n): () => void {\n\tconst prev =\n\t\ttypeof document !== \"undefined\"\n\t\t\t? (document.activeElement as HTMLElement | null)\n\t\t\t: null;\n\tconst wrap = options.wrap ?? true;\n\tconst onKeydown = (event: KeyboardEvent) => {\n\t\tif (event.key === \"Escape\") {\n\t\t\toptions.onEscape?.();\n\t\t\treturn;\n\t\t}\n\t\tif (event.key !== \"Tab\") return;\n\n\t\tconst focusable = getFocusableElements(container);\n\t\tif (!focusable.length) {\n\t\t\tif (!wrap) return;\n\t\t\tevent.preventDefault();\n\t\t\tcontainer.focus?.();\n\t\t\treturn;\n\t\t}\n\n\t\tconst current = document.activeElement as HTMLElement | null;\n\t\tconst currentIndex = focusable.indexOf(current || focusable[0]);\n\t\tif (event.shiftKey) {\n\t\t\tif (currentIndex <= 0) {\n\t\t\t\tif (wrap) {\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\tfocusable[focusable.length - 1].focus();\n\t\t\t\t} else {\n\t\t\t\t\toptions.onTabExit?.(\"backward\", event);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tif (currentIndex === focusable.length - 1) {\n\t\t\tif (wrap) {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tfocusable[0].focus();\n\t\t\t} else {\n\t\t\t\toptions.onTabExit?.(\"forward\", event);\n\t\t\t}\n\t\t}\n\t};\n\n\tqueueMicrotask(() => focusInitialTarget(container, options.initialFocus));\n\tcontainer.addEventListener(\"keydown\", onKeydown);\n\n\treturn () => {\n\t\tcontainer.removeEventListener(\"keydown\", onKeydown);\n\t\ttry {\n\t\t\tprev?.focus?.();\n\t\t} catch {\n\t\t\t// ignore\n\t\t}\n\t};\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pie-players/pie-players-shared",
3
- "version": "0.3.45",
3
+ "version": "0.3.46",
4
4
  "type": "module",
5
5
  "description": "Shared runtime + UI utilities for PIE players",
6
6
  "license": "MIT",