Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 83 additions & 9 deletions src/createAccessibilityTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,66 @@ interface AccessibilityContext {
visitedNodes: Set<Node>;
}

/**
* 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 <slot> → 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m a little concerned how well this scales and if there any optimisations we can make 🤔

Querying for the selector match and then again for the wildcard, recursed for every shadow root will be resulting in multiple visits per node and expect be a degree of memory cost higher than if we were to implement a tree walk (either TreeWalker, or manually DFS stack) and check each node against the selector (eg with matches).

If we need to genuinely hit every node on the page then we need to take care to get it right!

}
};

// Search the node itself and all its descendants
searchShadowRoots(node);
node.querySelectorAll("*").forEach(searchShadowRoots);

return results;
}

function addAlternateReadingOrderNodes(
node: Element,
alternateReadingOrderMap: Map<Node, Set<Node>>,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 <slot>, 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;
}
Expand Down
49 changes: 48 additions & 1 deletion src/getNodeByIdRef.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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("*")) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to previous comment here - a dfs walk would be more performant as would avoid the full sub tree scans, which suits given the early exit behaviour, so we should hopefully be able to achieve faster than O(n) on average

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;
}
60 changes: 50 additions & 10 deletions src/observeDOM.ts
Original file line number Diff line number Diff line change
@@ -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[] {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same again!

Though interestingly maybe should have read this part first 🤔

Given we are attaching mutation observers onto each shadow root, which means we (1) have a list of all shadow roots and (2) know whenever the change, there’s an opportunity here to create a cache of shadow roots which can be iterated over in the other methods, which expect would be far more performant as would reduce the full tree walk down to once.

Observation of changes could then invalidate that cache.

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,
Expand All @@ -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());
};
}

Expand Down
Loading
Loading