Skip to content
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"compile": "tsup",
"lint": "eslint src test --cache",
"lint:fix": "yarn lint --fix",
"prepare": "yarn build",
"prepublish": "yarn build",
"test": "jest",
"test:coverage": "yarn test --coverage",
Expand Down
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);
}
};

// 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("*")) {
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[] {
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