Skip to content

Commit 7cdeb29

Browse files
authored
feat: Update aria hideoutside to work with shadow doms (#9607)
* feat: update ariaHideOutside to work with shadowdom * Add tests from old PR and fix implementation * fix lint * fix typescript in node_modules
1 parent b8f8d1d commit 7cdeb29

File tree

7 files changed

+516
-15
lines changed

7 files changed

+516
-15
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@
197197
"regenerator-runtime": "0.13.3",
198198
"rehype-stringify": "^9.0.4",
199199
"rimraf": "^6.0.1",
200+
"shadow-dom-testing-library": "^1.13.1",
200201
"sharp": "^0.33.5",
201202
"storybook": "^8.6.14",
202203
"storybook-dark-mode": "^4.0.2",
@@ -243,7 +244,8 @@
243244
"lightningcss": "1.30.1",
244245
"react-server-dom-parcel": "canary",
245246
"react-test-renderer": "19.1.0",
246-
"@parcel/packager-react-static": "^2.16.3"
247+
"@parcel/packager-react-static": "^2.16.3",
248+
"@sinclair/typebox": "0.27.10"
247249
},
248250
"@parcel/transformer-css": {
249251
"cssModules": {

packages/@react-aria/overlays/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@react-aria/ssr": "^3.9.10",
3333
"@react-aria/utils": "^3.33.0",
3434
"@react-aria/visually-hidden": "^3.8.30",
35+
"@react-stately/flags": "^3.1.2",
3536
"@react-stately/overlays": "^3.6.22",
3637
"@react-types/button": "^3.15.0",
3738
"@react-types/overlays": "^3.9.3",

packages/@react-aria/overlays/src/ariaHideOutside.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {getOwnerWindow, nodeContains} from '@react-aria/utils';
13+
import {createShadowTreeWalker, getOwnerDocument, getOwnerWindow, nodeContains} from '@react-aria/utils';
14+
import {shadowDOM} from '@react-stately/flags';
15+
1416
const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype;
1517

1618
interface AriaHideOutsideOptions {
@@ -64,6 +66,22 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt
6466
}
6567
};
6668

69+
let shadowRootsToWatch = new Set<ShadowRoot>();
70+
if (shadowDOM()) {
71+
// find all shadow roots that are ancestors of the targets
72+
// traverse upwards until the root is reached
73+
for (let target of targets) {
74+
let node = target;
75+
while (node && node !== root) {
76+
let root = node.getRootNode();
77+
if ('shadowRoot' in root) {
78+
shadowRootsToWatch.add(root.shadowRoot as ShadowRoot);
79+
}
80+
node = root.parentNode as Element;
81+
}
82+
}
83+
}
84+
6785
let walk = (root: Element) => {
6886
// Keep live announcer and top layer elements (e.g. toasts) visible.
6987
for (let element of root.querySelectorAll('[data-live-announcer], [data-react-aria-top-layer]')) {
@@ -93,7 +111,8 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt
93111
return NodeFilter.FILTER_ACCEPT;
94112
};
95113

96-
let walker = document.createTreeWalker(
114+
let walker = createShadowTreeWalker(
115+
getOwnerDocument(root),
97116
root,
98117
NodeFilter.SHOW_ELEMENT,
99118
{acceptNode}
@@ -164,10 +183,65 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt
164183
}
165184
}
166185
}
186+
187+
if (shadowDOM()) {
188+
// if any of the observed shadow roots were removed, stop observing them
189+
for (let shadowRoot of shadowRootsToWatch) {
190+
if (!shadowRoot.isConnected) {
191+
observer.disconnect();
192+
break;
193+
}
194+
}
195+
}
167196
}
168197
});
169198

170199
observer.observe(root, {childList: true, subtree: true});
200+
let shadowObservers = new Set<MutationObserver>();
201+
if (shadowDOM()) {
202+
for (let shadowRoot of shadowRootsToWatch) {
203+
// Disconnect single target instead of all https://github.com/whatwg/dom/issues/126
204+
let shadowObserver = new MutationObserver(changes => {
205+
for (let change of changes) {
206+
if (change.type !== 'childList') {
207+
continue;
208+
}
209+
210+
// If the parent element of the added nodes is not within one of the targets,
211+
// and not already inside a hidden node, hide all of the new children.
212+
if (
213+
change.target.isConnected &&
214+
![...visibleNodes, ...hiddenNodes].some((node) =>
215+
nodeContains(node, change.target)
216+
)
217+
) {
218+
for (let node of change.addedNodes) {
219+
if (
220+
(node instanceof HTMLElement || node instanceof SVGElement) &&
221+
(node.dataset.liveAnnouncer === 'true' || node.dataset.reactAriaTopLayer === 'true')
222+
) {
223+
visibleNodes.add(node);
224+
} else if (node instanceof Element) {
225+
walk(node);
226+
}
227+
}
228+
}
229+
230+
if (shadowDOM()) {
231+
// if any of the observed shadow roots were removed, stop observing them
232+
for (let shadowRoot of shadowRootsToWatch) {
233+
if (!shadowRoot.isConnected) {
234+
observer.disconnect();
235+
break;
236+
}
237+
}
238+
}
239+
}
240+
});
241+
shadowObserver.observe(shadowRoot, {childList: true, subtree: true});
242+
shadowObservers.add(shadowObserver);
243+
}
244+
}
171245

172246
let observerWrapper: ObserverWrapper = {
173247
visibleNodes,
@@ -184,6 +258,11 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt
184258

185259
return (): void => {
186260
observer.disconnect();
261+
if (shadowDOM()) {
262+
for (let shadowObserver of shadowObservers) {
263+
shadowObserver.disconnect();
264+
}
265+
}
187266

188267
for (let node of hiddenNodes) {
189268
let count = refCountMap.get(node);

0 commit comments

Comments
 (0)