From 58c7cc29f8e4d2f4bf73affc6180f1dc2064f09c Mon Sep 17 00:00:00 2001 From: AdamMagued Date: Tue, 29 Sep 2026 23:42:59 +0000 Subject: [PATCH] fix(tree): traverse open shadow roots and resolved slots in accessibility tree --- src/Virtual.ts | 11 +- src/createAccessibilityTree.ts | 101 +++- src/getElementFromNode.ts | 7 +- src/getLiveSpokenPhrase.ts | 7 +- src/getNodeByIdRef.ts | 64 ++- src/isElement.ts | 4 +- src/observeDOM.ts | 66 ++- test/createAccessibilityTreeShadowDom.test.ts | 254 ++++++++++ test/int/shadowDom.int.test.ts | 460 ++++++++++++++++++ 9 files changed, 934 insertions(+), 40 deletions(-) create mode 100644 test/createAccessibilityTreeShadowDom.test.ts create mode 100644 test/int/shadowDom.int.test.ts diff --git a/src/Virtual.ts b/src/Virtual.ts index 051de4a..8671d90 100644 --- a/src/Virtual.ts +++ b/src/Virtual.ts @@ -302,7 +302,12 @@ export class Virtual { this.#treeCache = null; } - async #handleFocusChange({ target }: Event) { + async #handleFocusChange(event: Event) { + const target = + typeof event.composedPath === "function" && event.composedPath().length > 0 + ? (event.composedPath()[0] as Node) + : (event.target as Node); + await tick(); this.#invalidateTreeCache(); @@ -314,7 +319,9 @@ export class Virtual { // We've covered the tree having no length so there should be at least one // matching node, but if not we will not update the state - const newActiveNode = tree.find(({ node }) => node === target); + const newActiveNode = + tree.find(({ node }) => node === target) ?? + tree.find(({ node }) => node === event.target); if (!newActiveNode) { return; diff --git a/src/createAccessibilityTree.ts b/src/createAccessibilityTree.ts index 4868197..ff9cb9d 100644 --- a/src/createAccessibilityTree.ts +++ b/src/createAccessibilityTree.ts @@ -1,5 +1,6 @@ import { AccessibleAttributeToLabelMap } from "./getNodeAccessibilityData/getAccessibleAttributeLabels/index"; import { getIdRefsByAttribute } from "./getIdRefsByAttribute"; +import { getLocalName } from "./getLocalName"; import { getNodeAccessibilityData } from "./getNodeAccessibilityData/index"; import { getNodeByIdRef } from "./getNodeByIdRef"; import { isDialogRole } from "./isDialogRole"; @@ -39,10 +40,46 @@ interface AccessibilityContext { visitedNodes: Set; } +function querySelectorAllDeep(root: Node, selector: string): Element[] { + const results: Element[] = []; + + if (!isElement(root) && root.nodeType !== 9 && root.nodeType !== 11) { + return results; + } + + const container = root as Element | Document | DocumentFragment; + + if ( + isElement(container) && + typeof container.matches === "function" && + container.matches(selector) + ) { + results.push(container); + } + + if (typeof container.querySelectorAll === "function") { + results.push(...Array.from(container.querySelectorAll(selector))); + + const allDescendants = container.querySelectorAll("*"); + + for (const el of allDescendants) { + if (el.shadowRoot) { + results.push(...querySelectorAllDeep(el.shadowRoot, selector)); + } + } + } + + if (isElement(container) && container.shadowRoot) { + results.push(...querySelectorAllDeep(container.shadowRoot, selector)); + } + + return Array.from(new Set(results)); +} + function addAlternateReadingOrderNodes( node: Element, alternateReadingOrderMap: Map>, - container: Element + container: Node ) { const idRefs = getIdRefsByAttribute({ attributeName: "aria-flowto", @@ -68,15 +105,9 @@ function addAlternateReadingOrderNodes( function mapAlternateReadingOrder(node: Node) { const alternateReadingOrderMap = new Map>(); - if (!isElement(node)) { - return alternateReadingOrderMap; - } - - node - .querySelectorAll("[aria-flowto]") - .forEach((parentNode) => - addAlternateReadingOrderNodes(parentNode, alternateReadingOrderMap, node) - ); + querySelectorAllDeep(node, "[aria-flowto]").forEach((parentNode) => + addAlternateReadingOrderNodes(parentNode, alternateReadingOrderMap, node) + ); return alternateReadingOrderMap; } @@ -84,7 +115,7 @@ function mapAlternateReadingOrder(node: Node) { function addOwnedNodes( node: Element, ownedNodes: Set, - container: Element + container: Node ) { const idRefs = getIdRefsByAttribute({ attributeName: "aria-owns", @@ -103,13 +134,9 @@ function addOwnedNodes( function getAllOwnedNodes(node: Node) { const ownedNodes = new Set(); - if (!isElement(node)) { - return ownedNodes; - } - - node - .querySelectorAll("[aria-owns]") - .forEach((owningNode) => addOwnedNodes(owningNode, ownedNodes, node)); + querySelectorAllDeep(node, "[aria-owns]").forEach((owningNode) => + addOwnedNodes(owningNode, ownedNodes, node) + ); return ownedNodes; } @@ -117,7 +144,7 @@ function getAllOwnedNodes(node: Node) { function getOwnedNodes(node: Node, container: Node) { const ownedNodes = new Set(); - if (!isElement(node) || !isElement(container)) { + if (!isElement(node)) { return ownedNodes; } @@ -126,6 +153,30 @@ function getOwnedNodes(node: Node, container: Node) { return ownedNodes; } +function getChildNodes(node: Node): Node[] { + if (isElement(node)) { + if (node.shadowRoot) { + return Array.from(node.shadowRoot.childNodes); + } + + if (getLocalName(node) === "slot") { + const slot = node as HTMLSlotElement; + + if (typeof slot.assignedNodes === "function") { + const assignedNodes = slot.assignedNodes({ flatten: true }); + + if (assignedNodes.length > 0 || !node.childNodes.length) { + return assignedNodes; + } + } + + return Array.from(node.childNodes); + } + } + + return Array.from(node.childNodes); +} + function growTree( node: Node, tree: Omit< @@ -160,7 +211,7 @@ function growTree( tree.parentDialog = parentDialog; } - node.childNodes.forEach((childNode) => { + getChildNodes(node).forEach((childNode) => { if (isHiddenFromAccessibilityTree(childNode)) { return; } @@ -214,7 +265,10 @@ function growTree( { alternateReadingOrderMap, container, ownedNodes, visitedNodes } ); - if (isExplicitPresentational) { + if ( + isExplicitPresentational || + (isElement(childNode) && getLocalName(childNode) === "slot") + ) { tree.children.push(...childTree.children); } else { tree.children.push(childTree); @@ -283,7 +337,10 @@ function growTree( { alternateReadingOrderMap, container, ownedNodes, visitedNodes } ); - if (isExplicitPresentational) { + if ( + isExplicitPresentational || + (isElement(childNode) && getLocalName(childNode) === "slot") + ) { tree.children.push(...childTree.children); } else { tree.children.push(childTree); diff --git a/src/getElementFromNode.ts b/src/getElementFromNode.ts index ef36f6e..aaa50eb 100644 --- a/src/getElementFromNode.ts +++ b/src/getElementFromNode.ts @@ -1,5 +1,10 @@ import { isElement } from "./isElement"; export const getElementFromNode = (node: Node): HTMLElement => { - return isElement(node) ? node : node.parentElement!; + return isElement(node) + ? node + : (node.parentElement ?? + (((node.parentNode as (Node & { host?: HTMLElement }) | null) + ?.host as HTMLElement) || + (node.parentElement as HTMLElement))); }; diff --git a/src/getLiveSpokenPhrase.ts b/src/getLiveSpokenPhrase.ts index bc3e564..315f881 100644 --- a/src/getLiveSpokenPhrase.ts +++ b/src/getLiveSpokenPhrase.ts @@ -261,7 +261,12 @@ function getLiveRegionAttributes( }; } - const targetAncestor = target.parentElement; + const targetAncestor = + target.parentElement ?? + ((target.parentNode as (Node & { host?: HTMLElement }) | null)?.host as + | HTMLElement + | undefined) ?? + null; if (target === container || targetAncestor === null) { return { diff --git a/src/getNodeByIdRef.ts b/src/getNodeByIdRef.ts index ec1de69..2758024 100644 --- a/src/getNodeByIdRef.ts +++ b/src/getNodeByIdRef.ts @@ -1,5 +1,63 @@ import { isElement } from "./isElement"; +function isContainer( + node: Node +): node is Element | Document | DocumentFragment { + return ( + isElement(node) || + node.nodeType === 9 || // DOCUMENT_NODE + node.nodeType === 11 // DOCUMENT_FRAGMENT_NODE + ); +} + +function findNodeByIdDeep(root: Node, idRef: string): Element | null { + if (!isContainer(root) || !idRef) { + return null; + } + + const escaped = `#${CSS.escape(idRef)}`; + + if (typeof root.querySelector === "function") { + try { + const found = root.querySelector(escaped); + + if (found) { + return found; + } + } catch { + // ignore invalid selector error + } + } + + if (isElement(root) && root.id === idRef) { + return root; + } + + if (isElement(root) && root.shadowRoot) { + const found = findNodeByIdDeep(root.shadowRoot, idRef); + + if (found) { + return found; + } + } + + if (typeof root.querySelectorAll === "function") { + const allDescendants = root.querySelectorAll("*"); + + for (const el of allDescendants) { + if (el.shadowRoot) { + const found = findNodeByIdDeep(el.shadowRoot, idRef); + + if (found) { + return found; + } + } + } + } + + return null; +} + export function getNodeByIdRef({ container, idRef, @@ -7,9 +65,5 @@ export function getNodeByIdRef({ container: Node; idRef: string; }) { - if (!isElement(container) || !idRef) { - return null; - } - - return container.querySelector(`#${CSS.escape(idRef)}`); + return findNodeByIdDeep(container, idRef); } diff --git a/src/isElement.ts b/src/isElement.ts index 4fbac56..1f7843e 100644 --- a/src/isElement.ts +++ b/src/isElement.ts @@ -1,5 +1,5 @@ const ELEMENT_NODE = 1; -export function isElement(node: Node): node is HTMLElement { - return node.nodeType === ELEMENT_NODE; +export function isElement(node: Node | null | undefined): node is HTMLElement { + return !!node && node.nodeType === ELEMENT_NODE; } diff --git a/src/observeDOM.ts b/src/observeDOM.ts index 42b81a3..f5df8c1 100644 --- a/src/observeDOM.ts +++ b/src/observeDOM.ts @@ -1,6 +1,48 @@ import { isElement } from "./isElement"; import type { Root } from "./Virtual"; +const OBSERVER_CONFIG: MutationObserverInit = { + attributes: true, + characterData: true, + childList: true, + subtree: true, +}; + +function observeSubtree( + observer: MutationObserver, + node: Node, + observedNodes: Set +) { + if (observedNodes.has(node)) { + return; + } + + observer.observe(node, OBSERVER_CONFIG); + observedNodes.add(node); + + if (isElement(node) && node.shadowRoot) { + observeSubtree(observer, node.shadowRoot, observedNodes); + } + + if ( + isElement(node) || + node.nodeType === 9 || // DOCUMENT_NODE + node.nodeType === 11 // DOCUMENT_FRAGMENT_NODE + ) { + const root = node as Element | Document | DocumentFragment; + + if (typeof root.querySelectorAll === "function") { + const descendants = root.querySelectorAll("*"); + + for (const el of descendants) { + if (el.shadowRoot) { + observeSubtree(observer, el.shadowRoot, observedNodes); + } + } + } + } +} + export function observeDOM( root: Root | undefined, node: Node, @@ -14,17 +56,27 @@ export function observeDOM( typeof root !== "undefined" ? root?.MutationObserver : null; if (MutationObserver) { - const mutationObserver = new MutationObserver(onChange); + const observedNodes = new Set(); + + const mutationObserver = new MutationObserver( + (mutations: MutationRecord[], observer: MutationObserver) => { + for (const mutation of mutations) { + if (mutation.type === "childList") { + mutation.addedNodes.forEach((addedNode) => { + observeSubtree(observer, addedNode, observedNodes); + }); + } + } + + onChange(mutations, observer); + } + ); - mutationObserver.observe(node, { - attributes: true, - characterData: true, - childList: true, - subtree: true, - }); + observeSubtree(mutationObserver, node, observedNodes); return () => { mutationObserver.disconnect(); + observedNodes.clear(); }; } diff --git a/test/createAccessibilityTreeShadowDom.test.ts b/test/createAccessibilityTreeShadowDom.test.ts new file mode 100644 index 0000000..a2027d6 --- /dev/null +++ b/test/createAccessibilityTreeShadowDom.test.ts @@ -0,0 +1,254 @@ +import { createAccessibilityTree } from "../src/createAccessibilityTree.js"; +import { getElementFromNode } from "../src/getElementFromNode.js"; +import { getNodeByIdRef } from "../src/getNodeByIdRef.js"; + +describe("createAccessibilityTree with Shadow DOM", () => { + let customElementCounter = 0; + + function defineElement(template: string) { + const tagName = `test-elem-${++customElementCounter}`; + + class TestElement extends HTMLElement { + constructor() { + super(); + const shadow = this.attachShadow({ mode: "open" }); + shadow.innerHTML = template; + } + } + + customElements.define(tagName, TestElement); + return tagName; + } + + afterEach(() => { + document.body.innerHTML = ""; + }); + + describe("open shadow root traversal", () => { + it("should traverse children of an open shadowRoot", () => { + const tag = defineElement(` +

Shadow Title

+

Shadow Description

+ `); + + const container = document.createElement("div"); + container.innerHTML = `<${tag}>`; + document.body.appendChild(container); + + const tree = createAccessibilityTree(container); + + expect(tree).not.toBeNull(); + const hostNode = tree!.children[0]; + expect(hostNode).toBeDefined(); + + const shadowHeading = hostNode.children.find( + (c) => c.role === "heading" + ); + expect(shadowHeading).toBeDefined(); + expect(shadowHeading!.accessibleName).toBe("Shadow Title"); + + const shadowParagraph = hostNode.children.find( + (c) => c.role === "paragraph" + ); + expect(shadowParagraph).toBeDefined(); + }); + + it("should unwrap slot elements and traverse assigned nodes", () => { + const tag = defineElement(` +
Shadow Header
+ +
Shadow Footer
+ `); + + const container = document.createElement("div"); + container.innerHTML = `<${tag}>`; + document.body.appendChild(container); + + const tree = createAccessibilityTree(container); + + expect(tree).not.toBeNull(); + const hostNode = tree!.children[0]; + + // Children of host should be header, button (unwrapped through slot), and footer + const childRoles = hostNode.children.map((c) => c.role); + expect(childRoles).toEqual(["banner", "button", "contentinfo"]); + + const buttonNode = hostNode.children.find((c) => c.role === "button"); + expect(buttonNode!.accessibleName).toBe("Slotted Button"); + }); + + it("should handle slot fallback content when no nodes are assigned", () => { + const tag = defineElement(` +

Default Fallback

+ `); + + const container = document.createElement("div"); + container.innerHTML = `<${tag}>`; + document.body.appendChild(container); + + const tree = createAccessibilityTree(container); + + expect(tree).not.toBeNull(); + const hostNode = tree!.children[0]; + + const paragraphNode = hostNode.children.find( + (c) => c.role === "paragraph" + ); + expect(paragraphNode).toBeDefined(); + }); + + it("should exclude unslotted light DOM children from the tree", () => { + const tag = defineElement(` +
Shadow Only
+ `); + + const container = document.createElement("div"); + // This light DOM button is not assigned to any slot because shadow DOM has no slot + container.innerHTML = `<${tag}>`; + document.body.appendChild(container); + + const tree = createAccessibilityTree(container); + + expect(tree).not.toBeNull(); + const hostNode = tree!.children[0]; + + const buttonNode = hostNode.children.find((c) => c.role === "button"); + expect(buttonNode).toBeUndefined(); + }); + + it("should handle slot element in light DOM with children", () => { + const container = document.createElement("div"); + container.innerHTML = "

Light Slot Child

"; + document.body.appendChild(container); + + const tree = createAccessibilityTree(container); + expect(tree).not.toBeNull(); + const p = tree!.children.find((c) => c.role === "paragraph"); + expect(p).toBeDefined(); + }); + + it("should handle container element matching aria-owns", () => { + const container = document.createElement("div"); + container.setAttribute("aria-owns", "owned-child"); + container.innerHTML = "Owned"; + document.body.appendChild(container); + + const tree = createAccessibilityTree(container); + expect(tree).not.toBeNull(); + }); + }); + + describe("getNodeByIdRef with shadow DOM", () => { + it("should find elements inside open shadow roots", () => { + const tag = defineElement(` + + `); + + const host = document.createElement(tag); + document.body.appendChild(host); + + const found = getNodeByIdRef({ + container: document.body, + idRef: "shadow-btn", + }); + + expect(found).not.toBeNull(); + expect(found!.id).toBe("shadow-btn"); + expect(found!.textContent).toBe("Action"); + }); + + it("should find elements inside nested shadow roots", () => { + const innerTag = defineElement(` + + `); + const outerTag = defineElement(` + <${innerTag}> + `); + + const host = document.createElement(outerTag); + document.body.appendChild(host); + + const found = getNodeByIdRef({ + container: document.body, + idRef: "nested-input", + }); + + expect(found).not.toBeNull(); + expect(found!.id).toBe("nested-input"); + }); + + it("should return container itself if its id matches idRef", () => { + const container = document.createElement("div"); + container.id = "my-container-id"; + document.body.appendChild(container); + + const found = getNodeByIdRef({ + container, + idRef: "my-container-id", + }); + expect(found).toBe(container); + }); + + it("should find element in container shadowRoot when container is the custom element", () => { + const tag = defineElement(` + + `); + const host = document.createElement(tag); + document.body.appendChild(host); + + const found = getNodeByIdRef({ + container: host, + idRef: "direct-shadow-btn", + }); + expect(found).not.toBeNull(); + expect(found!.id).toBe("direct-shadow-btn"); + }); + + it("should return null for non-container node or empty idRef", () => { + const textNode = document.createTextNode("text"); + expect(getNodeByIdRef({ container: textNode, idRef: "foo" })).toBeNull(); + expect( + getNodeByIdRef({ container: document.body, idRef: "" }) + ).toBeNull(); + }); + + it("should return null when element id does not exist", () => { + const tag = defineElement(` +
Content
+ `); + + const host = document.createElement(tag); + document.body.appendChild(host); + + const found = getNodeByIdRef({ + container: document.body, + idRef: "nonexistent-id", + }); + + expect(found).toBeNull(); + }); + }); + + describe("getElementFromNode", () => { + it("should return element itself when node is an element", () => { + const el = document.createElement("div"); + expect(getElementFromNode(el)).toBe(el); + }); + + it("should return parentElement when node is a regular child text node", () => { + const el = document.createElement("div"); + el.textContent = "Text"; + const textNode = el.childNodes[0]; + expect(getElementFromNode(textNode)).toBe(el); + }); + + it("should return host element when node is a text node directly inside shadowRoot", () => { + const tag = defineElement("Direct text in shadow"); + const host = document.createElement(tag); + document.body.appendChild(host); + + const textNode = host.shadowRoot!.childNodes[0]; + expect(getElementFromNode(textNode)).toBe(host); + }); + }); +}); diff --git a/test/int/shadowDom.int.test.ts b/test/int/shadowDom.int.test.ts new file mode 100644 index 0000000..604f16f --- /dev/null +++ b/test/int/shadowDom.int.test.ts @@ -0,0 +1,460 @@ +import { virtual } from "../../src/index.js"; + +describe("Shadow DOM Integration", () => { + let customElementCounter = 0; + + function defineElement(template: string) { + const tagName = `shadow-elem-${++customElementCounter}`; + + class ShadowElement extends HTMLElement { + constructor() { + super(); + const shadow = this.attachShadow({ mode: "open" }); + shadow.innerHTML = template; + } + } + + customElements.define(tagName, ShadowElement); + return tagName; + } + + afterEach(async () => { + await virtual.stop(); + document.body.innerHTML = ""; + }); + + it("should speak all accessible content inside an open shadow root", async () => { + const tag = defineElement(` + +

Shadow Heading

+

Shadow Paragraph

+ + `); + + document.body.innerHTML = `<${tag}>`; + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + const spokenPhraseLog = await virtual.spokenPhraseLog(); + expect(spokenPhraseLog).toEqual([ + "document", + "navigation", + "Shadow Navigation", + "end of navigation", + "heading, Shadow Heading, level 1", + "paragraph", + "Shadow Paragraph", + "end of paragraph", + "button, Shadow Button", + "end of document", + ]); + }); + + it("should correctly interleave shadow DOM elements and slotted content in spoken phrase log", async () => { + const tag = defineElement(` +
+

Component Header

+
+
+ +
+
+ +
+ `); + + document.body.innerHTML = ` + <${tag}> +

Slotted Light DOM Content

+ + `; + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + const spokenPhraseLog = await virtual.spokenPhraseLog(); + expect(spokenPhraseLog).toEqual([ + "document", + "banner", + "heading, Component Header, level 1", + "end of banner", + "main", + "paragraph", + "Slotted Light DOM Content", + "end of paragraph", + "end of main", + "contentinfo", + "button, Action", + "end of contentinfo", + "end of document", + ]); + }); + + it("should resolve named slots in the order defined by shadow DOM structure", async () => { + const tag = defineElement(` +
+

+
+
+
+ `); + + document.body.innerHTML = ` + <${tag}> + + Card Title +

Card Description

+ + `; + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + const spokenPhraseLog = await virtual.spokenPhraseLog(); + expect(spokenPhraseLog).toEqual([ + "document", + "region, Card", + "heading, Card Title, level 2", + "Card Title", + "end of heading, Card Title, level 2", + "paragraph", + "Card Description", + "end of paragraph", + "button, Submit", + "end of region, Card", + "end of document", + ]); + }); + + it("should speak fallback content when nothing is assigned to a slot", async () => { + const tag = defineElement(` + +

Fallback Content

+
+ `); + + document.body.innerHTML = `<${tag}>`; + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + const spokenPhraseLog = await virtual.spokenPhraseLog(); + expect(spokenPhraseLog).toEqual([ + "document", + "paragraph", + "Fallback Content", + "end of paragraph", + "end of document", + ]); + }); + + it("should ignore unslotted light DOM elements", async () => { + const tag = defineElement(` +

Only Shadow Visible

+ `); + + document.body.innerHTML = ` + <${tag}> + +

Hidden Paragraph

+ + `; + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + const spokenPhraseLog = await virtual.spokenPhraseLog(); + expect(spokenPhraseLog).toEqual([ + "document", + "paragraph", + "Only Shadow Visible", + "end of paragraph", + "end of document", + ]); + }); + + it("should navigate shadow DOM headings using moveToNextHeading", async () => { + const tag = defineElement(` +

Heading in Shadow

+

Subheading in Shadow

+ `); + + document.body.innerHTML = `<${tag}>`; + + await virtual.start({ container: document.body }); + + await virtual.perform(virtual.commands.moveToNextHeading); + expect(await virtual.lastSpokenPhrase()).toBe( + "heading, Heading in Shadow, level 1" + ); + + await virtual.perform(virtual.commands.moveToNextHeading); + expect(await virtual.lastSpokenPhrase()).toBe( + "heading, Subheading in Shadow, level 2" + ); + }); + + it("should navigate shadow DOM landmarks using moveToNextLandmark", async () => { + const tag = defineElement(` +
Shadow Banner
+
Shadow Main
+
Shadow Footer
+ `); + + document.body.innerHTML = `<${tag}>`; + + await virtual.start({ container: document.body }); + + await virtual.perform(virtual.commands.moveToNextLandmark); + expect(await virtual.lastSpokenPhrase()).toBe("banner"); + + await virtual.perform(virtual.commands.moveToNextLandmark); + expect(await virtual.lastSpokenPhrase()).toBe("main"); + + await virtual.perform(virtual.commands.moveToNextLandmark); + expect(await virtual.lastSpokenPhrase()).toBe("contentinfo"); + }); + + it("should navigate shadow DOM links using moveToNextLink", async () => { + const tag = defineElement(` + Shadow Link + `); + + document.body.innerHTML = `<${tag}>`; + + await virtual.start({ container: document.body }); + + await virtual.perform(virtual.commands.moveToNextLink); + expect(await virtual.lastSpokenPhrase()).toBe("link, Shadow Link"); + }); + + it("should navigate shadow DOM landmarks using specific landmark commands", async () => { + const tag = defineElement(` +
Shadow Header
+
Shadow Content
+
Shadow Footer
+ `); + + document.body.innerHTML = `<${tag}>`; + + await virtual.start({ container: document.body }); + + await virtual.perform(virtual.commands.moveToNextBanner); + expect(await virtual.lastSpokenPhrase()).toBe("banner"); + + await virtual.perform(virtual.commands.moveToNextMain); + expect(await virtual.lastSpokenPhrase()).toBe("main"); + + await virtual.perform(virtual.commands.moveToNextContentinfo); + expect(await virtual.lastSpokenPhrase()).toBe("contentinfo"); + }); + + it("should allow clicking a button inside shadow DOM and trigger its handler", async () => { + let clicked = false; + const tagName = `clickable-elem-${++customElementCounter}`; + + class ClickableElement extends HTMLElement { + constructor() { + super(); + const shadow = this.attachShadow({ mode: "open" }); + const button = document.createElement("button"); + button.textContent = "Click Me"; + button.addEventListener("click", () => { + clicked = true; + }); + shadow.appendChild(button); + } + } + + customElements.define(tagName, ClickableElement); + + document.body.innerHTML = `<${tagName}>`; + + await virtual.start({ container: document.body }); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("button, Click Me"); + + await virtual.click(); + expect(clicked).toBe(true); + }); + + it("should update activeNode on focusin event for an element inside shadow DOM", async () => { + const tag = defineElement(` + + `); + + document.body.innerHTML = `<${tag}>`; + + await virtual.start({ container: document.body }); + + const host = document.querySelector(tag)!; + const btn = host.shadowRoot!.querySelector("button")!; + btn.focus(); + + // Allow event and tick to flush + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(virtual.activeNode).toBe(btn); + }); + + it("should work when starting virtual directly on the custom element as container", async () => { + const tag = defineElement(` + +

Component Paragraph

+ `); + + document.body.innerHTML = `<${tag}>`; + const host = document.querySelector(tag)!; + + await virtual.start({ container: host }); + + while ((await virtual.lastSpokenPhrase()) !== "end of navigation") { + await virtual.next(); + } + + const spokenPhraseLog = await virtual.spokenPhraseLog(); + expect(spokenPhraseLog).toContain("navigation"); + expect(spokenPhraseLog).toContain("Component Nav"); + expect(spokenPhraseLog).toContain("end of navigation"); + }); + + it("should handle nested web components with shadow DOM", async () => { + const childTag = defineElement(` + + `); + const parentTag = defineElement(` +

Outer Heading

+ <${childTag}> + `); + + document.body.innerHTML = `<${parentTag}>`; + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + const spokenPhraseLog = await virtual.spokenPhraseLog(); + expect(spokenPhraseLog).toEqual([ + "document", + "heading, Outer Heading, level 1", + "button, Inner Button", + "end of document", + ]); + }); + + it("should support aria-flowto targeting an element inside shadow DOM", async () => { + const tag = defineElement(` +

Shadow Flow Target

+ `); + + document.body.innerHTML = ` + + <${tag}> + `; + + await virtual.start({ container: document.body }); + + // Move to start button + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toContain("Start Button"); + + // Move along alternate reading order + await virtual.perform( + virtual.commands.moveToNextAlternateReadingOrderElement + ); + expect(await virtual.lastSpokenPhrase()).toBe( + "paragraph, 1 previous alternate reading order" + ); + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("Shadow Flow Target"); + }); + + it("should support aria-owns with an element inside shadow DOM", async () => { + const tag = defineElement(` +
Shadow Owned Button
+ `); + + document.body.innerHTML = ` +
+ <${tag}> + `; + + await virtual.start({ container: document.body }); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("region, Owning Region"); + + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("button, Shadow Owned Button"); + }); + + it("should react to dynamic mutations inside shadow DOM", async () => { + const tag = defineElement(` +

Initial Text

+ `); + + document.body.innerHTML = `<${tag}>`; + + await virtual.start({ container: document.body }); + + await virtual.next(); // paragraph + await virtual.next(); // Initial Text + expect(await virtual.lastSpokenPhrase()).toBe("Initial Text"); + + // Dynamically mutate inside shadow root + const host = document.querySelector(tag)!; + const para = host.shadowRoot!.querySelector("#dynamic-text")!; + para.textContent = "Updated Text"; + + // Wait for mutation observer + await new Promise((resolve) => setTimeout(resolve, 50)); + + // When the active text node is replaced in the DOM, virtual gracefully resets to container ("document") + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("document"); + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("paragraph"); + await virtual.next(); + expect(await virtual.lastSpokenPhrase()).toBe("Updated Text"); + }); + + it("should handle live region mutations inside shadow DOM", async () => { + const tag = defineElement(` +

Initial Live

+ `); + + document.body.innerHTML = `<${tag} aria-live="polite">`; + + await virtual.start({ container: document.body }); + + const host = document.querySelector(tag)!; + const p = host.shadowRoot!.querySelector("#live-text")!; + p.textContent = "Polite Update"; + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const spokenPhraseLog = await virtual.spokenPhraseLog(); + expect( + spokenPhraseLog.some((phrase) => phrase.includes("polite: Polite Update")) + ).toBe(true); + }); +});