Skip to content

Commit 1958fa3

Browse files
authored
Merge pull request #1 from quatico-solutions/quatico-installable
Shadow DOM traversal + git-installable build
2 parents d3c7936 + f35ab8f commit 1958fa3

5 files changed

Lines changed: 481 additions & 20 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"compile": "tsup",
5353
"lint": "eslint src test --cache",
5454
"lint:fix": "yarn lint --fix",
55+
"prepare": "yarn build",
5556
"prepublish": "yarn build",
5657
"test": "jest",
5758
"test:coverage": "yarn test --coverage",

src/createAccessibilityTree.ts

Lines changed: 83 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,66 @@ interface AccessibilityContext {
3939
visitedNodes: Set<Node>;
4040
}
4141

42+
/**
43+
* Returns the child nodes to traverse for building the flattened
44+
* accessibility tree, handling shadow DOM and slot projection:
45+
*
46+
* 1. If the node has an open shadow root → return shadow root's children
47+
* 2. If the node is a <slot> → return assigned nodes (or default content)
48+
* 3. Otherwise → return the node's direct children
49+
*/
50+
function getAccessibleChildNodes(node: Node): Node[] {
51+
// Shadow host: traverse into the shadow tree
52+
if (isElement(node) && node.shadowRoot) {
53+
return Array.from(node.shadowRoot.childNodes);
54+
}
55+
56+
// Slot element: traverse assigned (projected) content, or default content
57+
if (isElement(node) && node.localName === "slot") {
58+
const slot = node as HTMLSlotElement;
59+
const assigned = slot.assignedNodes({ flatten: true });
60+
61+
if (assigned.length > 0) {
62+
return assigned;
63+
}
64+
65+
// No assigned content — fall through to default slot content (childNodes)
66+
}
67+
68+
return Array.from(node.childNodes);
69+
}
70+
71+
/**
72+
* Shadow-aware querySelectorAll: searches the node and all descendant
73+
* shadow roots for elements matching the selector.
74+
*/
75+
function deepQuerySelectorAll(
76+
node: Node,
77+
selector: string
78+
): Element[] {
79+
if (!isElement(node)) {
80+
return [];
81+
}
82+
83+
const results: Element[] = Array.from(node.querySelectorAll(selector));
84+
85+
// Also search inside shadow roots
86+
const searchShadowRoots = (root: Element) => {
87+
if (root.shadowRoot) {
88+
results.push(
89+
...Array.from(root.shadowRoot.querySelectorAll(selector))
90+
);
91+
root.shadowRoot.querySelectorAll("*").forEach(searchShadowRoots);
92+
}
93+
};
94+
95+
// Search the node itself and all its descendants
96+
searchShadowRoots(node);
97+
node.querySelectorAll("*").forEach(searchShadowRoots);
98+
99+
return results;
100+
}
101+
42102
function addAlternateReadingOrderNodes(
43103
node: Element,
44104
alternateReadingOrderMap: Map<Node, Set<Node>>,
@@ -72,11 +132,9 @@ function mapAlternateReadingOrder(node: Node) {
72132
return alternateReadingOrderMap;
73133
}
74134

75-
node
76-
.querySelectorAll("[aria-flowto]")
77-
.forEach((parentNode) =>
78-
addAlternateReadingOrderNodes(parentNode, alternateReadingOrderMap, node)
79-
);
135+
deepQuerySelectorAll(node, "[aria-flowto]").forEach((parentNode) =>
136+
addAlternateReadingOrderNodes(parentNode, alternateReadingOrderMap, node)
137+
);
80138

81139
return alternateReadingOrderMap;
82140
}
@@ -107,9 +165,9 @@ function getAllOwnedNodes(node: Node) {
107165
return ownedNodes;
108166
}
109167

110-
node
111-
.querySelectorAll("[aria-owns]")
112-
.forEach((owningNode) => addOwnedNodes(owningNode, ownedNodes, node));
168+
deepQuerySelectorAll(node, "[aria-owns]").forEach((owningNode) =>
169+
addOwnedNodes(owningNode, ownedNodes, node)
170+
);
113171

114172
return ownedNodes;
115173
}
@@ -160,7 +218,23 @@ function growTree(
160218
tree.parentDialog = parentDialog;
161219
}
162220

163-
node.childNodes.forEach((childNode) => {
221+
/**
222+
* Determine which child nodes to traverse based on the flattened tree:
223+
*
224+
* - If the node has an open shadow root, traverse the shadow tree instead
225+
* of the light DOM children (shadow DOM replaces light DOM in the
226+
* accessibility tree).
227+
* - If a child is a <slot>, traverse its assigned nodes (the projected
228+
* light DOM content). If no nodes are assigned, fall back to the slot's
229+
* default content (its own childNodes).
230+
* - Otherwise, traverse the node's direct childNodes (standard light DOM).
231+
*
232+
* REF: https://www.w3.org/TR/wai-aria-1.2/#accessibility_tree
233+
* REF: https://developer.mozilla.org/en-US/docs/Web/API/HTMLSlotElement/assignedNodes
234+
*/
235+
const childNodes = getAccessibleChildNodes(node);
236+
237+
childNodes.forEach((childNode) => {
164238
if (isHiddenFromAccessibilityTree(childNode)) {
165239
return;
166240
}

src/getNodeByIdRef.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { isElement } from "./isElement";
22

3+
/**
4+
* Shadow-aware ID lookup: searches the container and all descendant
5+
* shadow roots for an element with the given ID.
6+
*/
37
export function getNodeByIdRef({
48
container,
59
idRef,
@@ -11,5 +15,48 @@ export function getNodeByIdRef({
1115
return null;
1216
}
1317

14-
return container.querySelector(`#${CSS.escape(idRef)}`);
18+
const selector = `#${CSS.escape(idRef)}`;
19+
20+
// Try light DOM first
21+
const result = container.querySelector(selector);
22+
23+
if (result) {
24+
return result;
25+
}
26+
27+
// Search inside shadow roots
28+
return findInShadowRoots(container, selector);
29+
}
30+
31+
function findInShadowRoots(
32+
root: Element,
33+
selector: string
34+
): Element | null {
35+
if (root.shadowRoot) {
36+
const found = root.shadowRoot.querySelector(selector);
37+
38+
if (found) {
39+
return found;
40+
}
41+
42+
for (const child of root.shadowRoot.querySelectorAll("*")) {
43+
const result = findInShadowRoots(child, selector);
44+
45+
if (result) {
46+
return result;
47+
}
48+
}
49+
}
50+
51+
for (const child of root.querySelectorAll("*")) {
52+
if (child.shadowRoot) {
53+
const result = findInShadowRoots(child, selector);
54+
55+
if (result) {
56+
return result;
57+
}
58+
}
59+
}
60+
61+
return null;
1562
}

src/observeDOM.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,39 @@
11
import { isElement } from "./isElement";
22
import type { Root } from "./Virtual";
33

4+
const OBSERVE_OPTIONS: MutationObserverInit = {
5+
attributes: true,
6+
characterData: true,
7+
childList: true,
8+
subtree: true,
9+
};
10+
11+
/**
12+
* Recursively find all open shadow roots within a node tree.
13+
*/
14+
function collectShadowRoots(node: Node): ShadowRoot[] {
15+
const roots: ShadowRoot[] = [];
16+
17+
if (isElement(node) && node.shadowRoot) {
18+
roots.push(node.shadowRoot);
19+
node.shadowRoot.querySelectorAll("*").forEach((child) => {
20+
if (child.shadowRoot) {
21+
roots.push(...collectShadowRoots(child));
22+
}
23+
});
24+
}
25+
26+
if (isElement(node)) {
27+
node.querySelectorAll("*").forEach((child) => {
28+
if (child.shadowRoot) {
29+
roots.push(...collectShadowRoots(child));
30+
}
31+
});
32+
}
33+
34+
return roots;
35+
}
36+
437
export function observeDOM(
538
root: Root | undefined,
639
node: Node,
@@ -10,21 +43,28 @@ export function observeDOM(
1043
return () => {};
1144
}
1245

13-
const MutationObserver =
46+
const MutationObserverCtor =
1447
typeof root !== "undefined" ? root?.MutationObserver : null;
1548

16-
if (MutationObserver) {
17-
const mutationObserver = new MutationObserver(onChange);
49+
if (MutationObserverCtor) {
50+
const observers: MutationObserver[] = [];
1851

19-
mutationObserver.observe(node, {
20-
attributes: true,
21-
characterData: true,
22-
childList: true,
23-
subtree: true,
24-
});
52+
// Observe the main container
53+
const mainObserver = new MutationObserverCtor(onChange);
54+
mainObserver.observe(node, OBSERVE_OPTIONS);
55+
observers.push(mainObserver);
56+
57+
// Observe all shadow roots within the container
58+
const shadowRoots = collectShadowRoots(node);
59+
60+
for (const shadowRoot of shadowRoots) {
61+
const shadowObserver = new MutationObserverCtor(onChange);
62+
shadowObserver.observe(shadowRoot, OBSERVE_OPTIONS);
63+
observers.push(shadowObserver);
64+
}
2565

2666
return () => {
27-
mutationObserver.disconnect();
67+
observers.forEach((observer) => observer.disconnect());
2868
};
2969
}
3070

0 commit comments

Comments
 (0)