From 6440dbcd99ae41095a1a48a751c3006a3cc8470e Mon Sep 17 00:00:00 2001 From: Qubert Date: Thu, 2 Apr 2026 13:10:27 +0200 Subject: [PATCH 1/3] feat: traverse open shadow DOM roots in accessibility tree The tree builder now follows the WAI-ARIA flattened tree model: - When a node has an open shadowRoot, traverse shadow children instead of light DOM children - Resolve elements via assignedNodes({ flatten: true }) - Shadow-aware querySelectorAll for aria-flowto and aria-owns resolution - Shadow-aware ID lookup for getNodeByIdRef - MutationObserver now observes shadow roots in addition to the container Fixes #182 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/createAccessibilityTree.ts | 92 ++++++++++++++++++++++++++++++---- src/getNodeByIdRef.ts | 49 +++++++++++++++++- src/observeDOM.ts | 60 ++++++++++++++++++---- 3 files changed, 181 insertions(+), 20 deletions(-) diff --git a/src/createAccessibilityTree.ts b/src/createAccessibilityTree.ts index 4868197..a38c7f7 100644 --- a/src/createAccessibilityTree.ts +++ b/src/createAccessibilityTree.ts @@ -39,6 +39,66 @@ interface AccessibilityContext { visitedNodes: Set; } +/** + * Returns the child nodes to traverse for building the flattened + * accessibility tree, handling shadow DOM and slot projection: + * + * 1. If the node has an open shadow root → return shadow root's children + * 2. If the node is a → return assigned nodes (or default content) + * 3. Otherwise → return the node's direct children + */ +function getAccessibleChildNodes(node: Node): Node[] { + // Shadow host: traverse into the shadow tree + if (isElement(node) && node.shadowRoot) { + return Array.from(node.shadowRoot.childNodes); + } + + // Slot element: traverse assigned (projected) content, or default content + if (isElement(node) && node.localName === "slot") { + const slot = node as HTMLSlotElement; + const assigned = slot.assignedNodes({ flatten: true }); + + if (assigned.length > 0) { + return assigned; + } + + // No assigned content — fall through to default slot content (childNodes) + } + + return Array.from(node.childNodes); +} + +/** + * Shadow-aware querySelectorAll: searches the node and all descendant + * shadow roots for elements matching the selector. + */ +function deepQuerySelectorAll( + node: Node, + selector: string +): Element[] { + if (!isElement(node)) { + return []; + } + + const results: Element[] = Array.from(node.querySelectorAll(selector)); + + // Also search inside shadow roots + const searchShadowRoots = (root: Element) => { + if (root.shadowRoot) { + results.push( + ...Array.from(root.shadowRoot.querySelectorAll(selector)) + ); + root.shadowRoot.querySelectorAll("*").forEach(searchShadowRoots); + } + }; + + // Search the node itself and all its descendants + searchShadowRoots(node); + node.querySelectorAll("*").forEach(searchShadowRoots); + + return results; +} + function addAlternateReadingOrderNodes( node: Element, alternateReadingOrderMap: Map>, @@ -72,11 +132,9 @@ function mapAlternateReadingOrder(node: Node) { return alternateReadingOrderMap; } - node - .querySelectorAll("[aria-flowto]") - .forEach((parentNode) => - addAlternateReadingOrderNodes(parentNode, alternateReadingOrderMap, node) - ); + deepQuerySelectorAll(node, "[aria-flowto]").forEach((parentNode) => + addAlternateReadingOrderNodes(parentNode, alternateReadingOrderMap, node) + ); return alternateReadingOrderMap; } @@ -107,9 +165,9 @@ function getAllOwnedNodes(node: Node) { return ownedNodes; } - node - .querySelectorAll("[aria-owns]") - .forEach((owningNode) => addOwnedNodes(owningNode, ownedNodes, node)); + deepQuerySelectorAll(node, "[aria-owns]").forEach((owningNode) => + addOwnedNodes(owningNode, ownedNodes, node) + ); return ownedNodes; } @@ -160,7 +218,23 @@ function growTree( tree.parentDialog = parentDialog; } - node.childNodes.forEach((childNode) => { + /** + * Determine which child nodes to traverse based on the flattened tree: + * + * - If the node has an open shadow root, traverse the shadow tree instead + * of the light DOM children (shadow DOM replaces light DOM in the + * accessibility tree). + * - If a child is a , traverse its assigned nodes (the projected + * light DOM content). If no nodes are assigned, fall back to the slot's + * default content (its own childNodes). + * - Otherwise, traverse the node's direct childNodes (standard light DOM). + * + * REF: https://www.w3.org/TR/wai-aria-1.2/#accessibility_tree + * REF: https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/assignedNodes + */ + const childNodes = getAccessibleChildNodes(node); + + childNodes.forEach((childNode) => { if (isHiddenFromAccessibilityTree(childNode)) { return; } diff --git a/src/getNodeByIdRef.ts b/src/getNodeByIdRef.ts index ec1de69..61c3b82 100644 --- a/src/getNodeByIdRef.ts +++ b/src/getNodeByIdRef.ts @@ -1,5 +1,9 @@ import { isElement } from "./isElement"; +/** + * Shadow-aware ID lookup: searches the container and all descendant + * shadow roots for an element with the given ID. + */ export function getNodeByIdRef({ container, idRef, @@ -11,5 +15,48 @@ export function getNodeByIdRef({ return null; } - return container.querySelector(`#${CSS.escape(idRef)}`); + const selector = `#${CSS.escape(idRef)}`; + + // Try light DOM first + const result = container.querySelector(selector); + + if (result) { + return result; + } + + // Search inside shadow roots + return findInShadowRoots(container, selector); +} + +function findInShadowRoots( + root: Element, + selector: string +): Element | null { + if (root.shadowRoot) { + const found = root.shadowRoot.querySelector(selector); + + if (found) { + return found; + } + + for (const child of root.shadowRoot.querySelectorAll("*")) { + const result = findInShadowRoots(child, selector); + + if (result) { + return result; + } + } + } + + for (const child of root.querySelectorAll("*")) { + if (child.shadowRoot) { + const result = findInShadowRoots(child, selector); + + if (result) { + return result; + } + } + } + + return null; } diff --git a/src/observeDOM.ts b/src/observeDOM.ts index 42b81a3..f9f50c4 100644 --- a/src/observeDOM.ts +++ b/src/observeDOM.ts @@ -1,6 +1,39 @@ import { isElement } from "./isElement"; import type { Root } from "./Virtual"; +const OBSERVE_OPTIONS: MutationObserverInit = { + attributes: true, + characterData: true, + childList: true, + subtree: true, +}; + +/** + * Recursively find all open shadow roots within a node tree. + */ +function collectShadowRoots(node: Node): ShadowRoot[] { + const roots: ShadowRoot[] = []; + + if (isElement(node) && node.shadowRoot) { + roots.push(node.shadowRoot); + node.shadowRoot.querySelectorAll("*").forEach((child) => { + if (child.shadowRoot) { + roots.push(...collectShadowRoots(child)); + } + }); + } + + if (isElement(node)) { + node.querySelectorAll("*").forEach((child) => { + if (child.shadowRoot) { + roots.push(...collectShadowRoots(child)); + } + }); + } + + return roots; +} + export function observeDOM( root: Root | undefined, node: Node, @@ -10,21 +43,28 @@ export function observeDOM( return () => {}; } - const MutationObserver = + const MutationObserverCtor = typeof root !== "undefined" ? root?.MutationObserver : null; - if (MutationObserver) { - const mutationObserver = new MutationObserver(onChange); + if (MutationObserverCtor) { + const observers: MutationObserver[] = []; - mutationObserver.observe(node, { - attributes: true, - characterData: true, - childList: true, - subtree: true, - }); + // Observe the main container + const mainObserver = new MutationObserverCtor(onChange); + mainObserver.observe(node, OBSERVE_OPTIONS); + observers.push(mainObserver); + + // Observe all shadow roots within the container + const shadowRoots = collectShadowRoots(node); + + for (const shadowRoot of shadowRoots) { + const shadowObserver = new MutationObserverCtor(onChange); + shadowObserver.observe(shadowRoot, OBSERVE_OPTIONS); + observers.push(shadowObserver); + } return () => { - mutationObserver.disconnect(); + observers.forEach((observer) => observer.disconnect()); }; } From ff0ba7e16025fe159916981e8bd4df24bdc1c50c Mon Sep 17 00:00:00 2001 From: Qubert Date: Thu, 2 Apr 2026 13:23:41 +0200 Subject: [PATCH 2/3] test: add shadow DOM traversal tests 6 tests covering: - Open shadow root traversal - Nested shadow roots (2 levels deep) - Slotted content projection via assignedNodes - Slot default content fallback - Host element ARIA attributes with shadow children - Closed shadow roots are not traversed Co-Authored-By: Claude Opus 4.6 (1M context) --- test/int/shadowDom.int.test.ts | 189 +++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 test/int/shadowDom.int.test.ts diff --git a/test/int/shadowDom.int.test.ts b/test/int/shadowDom.int.test.ts new file mode 100644 index 0000000..5b31076 --- /dev/null +++ b/test/int/shadowDom.int.test.ts @@ -0,0 +1,189 @@ +import { virtual } from "../../src/index.js"; + +describe("Shadow DOM", () => { + afterEach(async () => { + await virtual.stop(); + + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + }); + + it("should traverse into an open shadow root", async () => { + const host = document.createElement("div"); + document.body.appendChild(host); + + const shadow = host.attachShadow({ mode: "open" }); + const nav = document.createElement("nav"); + nav.setAttribute("aria-label", "Shadow Nav"); + const button = document.createElement("button"); + button.textContent = "Click me"; + nav.appendChild(button); + shadow.appendChild(nav); + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "navigation, Shadow Nav", + "button, Click me", + "end of navigation, Shadow Nav", + "end of document", + ]); + }); + + it("should traverse nested shadow roots", async () => { + const outerHost = document.createElement("div"); + document.body.appendChild(outerHost); + + const outerShadow = outerHost.attachShadow({ mode: "open" }); + const innerHost = document.createElement("div"); + outerShadow.appendChild(innerHost); + + const innerShadow = innerHost.attachShadow({ mode: "open" }); + const nav = document.createElement("nav"); + nav.setAttribute("aria-label", "Deep Nav"); + const button = document.createElement("button"); + button.textContent = "Deep button"; + nav.appendChild(button); + innerShadow.appendChild(nav); + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "navigation, Deep Nav", + "button, Deep button", + "end of navigation, Deep Nav", + "end of document", + ]); + }); + + it("should resolve slotted content", async () => { + const host = document.createElement("div"); + document.body.appendChild(host); + + const shadow = host.attachShadow({ mode: "open" }); + const nav = document.createElement("nav"); + nav.setAttribute("aria-label", "Slotted Nav"); + const slot = document.createElement("slot"); + nav.appendChild(slot); + shadow.appendChild(nav); + + // Light DOM child — projected into the slot + const button = document.createElement("button"); + button.textContent = "Slotted button"; + host.appendChild(button); + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "navigation, Slotted Nav", + "button, Slotted button", + "end of navigation, Slotted Nav", + "end of document", + ]); + }); + + it("should use slot default content when no nodes are assigned", async () => { + const host = document.createElement("div"); + document.body.appendChild(host); + + const shadow = host.attachShadow({ mode: "open" }); + const slot = document.createElement("slot"); + const fallback = document.createElement("span"); + fallback.textContent = "Default content"; + slot.appendChild(fallback); + shadow.appendChild(slot); + + // No light DOM children — slot default content should be used + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + expect(await virtual.spokenPhraseLog()).toEqual([ + "document", + "Default content", + "end of document", + ]); + }); + + it("should handle shadow DOM host with aria attributes", async () => { + const host = document.createElement("div"); + host.setAttribute("role", "navigation"); + host.setAttribute("aria-label", "Host Nav"); + document.body.appendChild(host); + + const shadow = host.attachShadow({ mode: "open" }); + const list = document.createElement("ul"); + const item = document.createElement("li"); + const link = document.createElement("a"); + link.href = "#"; + link.textContent = "Nav item"; + item.appendChild(link); + list.appendChild(item); + shadow.appendChild(list); + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + const log = await virtual.spokenPhraseLog(); + + // Verify the host's role and aria-label are announced + expect(log).toContain("navigation, Host Nav"); + // Verify the shadow DOM content (list, listitem, link) is traversed + expect(log).toContain("list"); + expect(log).toContain("listitem, level 1, position 1, set size 1"); + expect(log).toContain("link, Nav item"); + expect(log).toContain("end of navigation, Host Nav"); + expect(log).toContain("end of document"); + }); + + it("should not traverse closed shadow roots", async () => { + const host = document.createElement("div"); + document.body.appendChild(host); + + // Closed shadow root — host.shadowRoot is null + host.attachShadow({ mode: "closed" }).appendChild( + Object.assign(document.createElement("button"), { + textContent: "Hidden button", + }) + ); + + // Add visible content outside + const visible = document.createElement("span"); + visible.textContent = "Visible text"; + document.body.appendChild(visible); + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + const log = await virtual.spokenPhraseLog(); + + // The closed shadow content should NOT appear + expect(log).not.toContain("button, Hidden button"); + expect(log).toContain("Visible text"); + }); +}); From 09b76f58122d24a52ab46a29792226917ef6a9d1 Mon Sep 17 00:00:00 2001 From: Qubert Date: Thu, 2 Apr 2026 15:29:33 +0200 Subject: [PATCH 3/3] test: add coverage for shadow-aware aria-owns, aria-flowto, and nested ID lookup - aria-owns referencing element inside shadow DOM - aria-owns referencing element nested 2 shadow levels deep - aria-flowto referencing element inside shadow DOM - Fixes coverage threshold failures (getNodeByIdRef now 100%) Co-Authored-By: Claude Opus 4.6 (1M context) --- test/int/shadowDom.int.test.ts | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/test/int/shadowDom.int.test.ts b/test/int/shadowDom.int.test.ts index 5b31076..81ac434 100644 --- a/test/int/shadowDom.int.test.ts +++ b/test/int/shadowDom.int.test.ts @@ -158,6 +158,116 @@ describe("Shadow DOM", () => { expect(log).toContain("end of document"); }); + it("should resolve aria-owns referencing an element inside shadow DOM", async () => { + // Container with aria-owns pointing to an ID inside a shadow root + const owner = document.createElement("div"); + owner.setAttribute("role", "listbox"); + owner.setAttribute("aria-label", "Owner"); + owner.setAttribute("aria-owns", "shadow-option"); + document.body.appendChild(owner); + + const host = document.createElement("div"); + document.body.appendChild(host); + + const shadow = host.attachShadow({ mode: "open" }); + const option = document.createElement("div"); + option.setAttribute("role", "option"); + option.setAttribute("id", "shadow-option"); + option.textContent = "Shadow Option"; + shadow.appendChild(option); + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + const log = await virtual.spokenPhraseLog(); + + // The owned element inside shadow DOM should be found and reparented. + // Listbox and option roles include extra ARIA attribute announcements. + const hasListbox = log.some((p) => p.includes("listbox, Owner")); + const hasOption = log.some( + (p) => p.includes("option, Shadow Option") && p.includes("position 1") + ); + expect(hasListbox).toBe(true); + expect(hasOption).toBe(true); + }); + + it("should resolve aria-flowto referencing an element inside shadow DOM", async () => { + const host = document.createElement("div"); + document.body.appendChild(host); + + const shadow = host.attachShadow({ mode: "open" }); + const target = document.createElement("div"); + target.setAttribute("id", "flow-target"); + target.setAttribute("role", "region"); + target.setAttribute("aria-label", "Target Region"); + target.textContent = "Flow target content"; + shadow.appendChild(target); + + // Source element with aria-flowto pointing into shadow DOM + const source = document.createElement("button"); + source.setAttribute("aria-flowto", "flow-target"); + source.textContent = "Source"; + document.body.appendChild(source); + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + const log = await virtual.spokenPhraseLog(); + + // Both the source and the shadow DOM target should be in the tree. + // aria-flowto adds "alternate reading order" annotations to spoken output. + const hasSource = log.some((p) => p.includes("button, Source")); + const hasTarget = log.some((p) => p.includes("region, Target Region")); + expect(hasSource).toBe(true); + expect(hasTarget).toBe(true); + }); + + it("should resolve aria-owns referencing an element nested two shadow levels deep", async () => { + const owner = document.createElement("div"); + owner.setAttribute("role", "listbox"); + owner.setAttribute("aria-label", "Deep Owner"); + owner.setAttribute("aria-owns", "deep-option"); + document.body.appendChild(owner); + + // Level 1: outer shadow host + const outerHost = document.createElement("div"); + document.body.appendChild(outerHost); + const outerShadow = outerHost.attachShadow({ mode: "open" }); + + // Level 2: inner shadow host inside outer shadow + const innerHost = document.createElement("div"); + outerShadow.appendChild(innerHost); + const innerShadow = innerHost.attachShadow({ mode: "open" }); + + // The target element is inside the innermost shadow root + const option = document.createElement("div"); + option.setAttribute("role", "option"); + option.setAttribute("id", "deep-option"); + option.textContent = "Deep Option"; + innerShadow.appendChild(option); + + await virtual.start({ container: document.body }); + + while ((await virtual.lastSpokenPhrase()) !== "end of document") { + await virtual.next(); + } + + const log = await virtual.spokenPhraseLog(); + + const hasListbox = log.some((p) => p.includes("listbox, Deep Owner")); + const hasOption = log.some( + (p) => p.includes("option, Deep Option") && p.includes("position 1") + ); + expect(hasListbox).toBe(true); + expect(hasOption).toBe(true); + }); + it("should not traverse closed shadow roots", async () => { const host = document.createElement("div"); document.body.appendChild(host);