diff --git a/package.json b/package.json index 9760520038d..840f2dcfd5d 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,7 @@ "regenerator-runtime": "0.13.3", "rehype-stringify": "^9.0.4", "rimraf": "^6.0.1", + "shadow-dom-testing-library": "^1.13.1", "sharp": "^0.33.5", "storybook": "^8.6.14", "storybook-dark-mode": "^4.0.2", @@ -242,7 +243,8 @@ "lightningcss": "1.30.1", "react-server-dom-parcel": "canary", "react-test-renderer": "19.1.0", - "@parcel/packager-react-static": "^2.16.3" + "@parcel/packager-react-static": "^2.16.3", + "@sinclair/typebox": "0.27.10" }, "@parcel/transformer-css": { "cssModules": { diff --git a/packages/@react-aria/overlays/package.json b/packages/@react-aria/overlays/package.json index 558df4c10d7..b0164fa5da8 100644 --- a/packages/@react-aria/overlays/package.json +++ b/packages/@react-aria/overlays/package.json @@ -32,6 +32,7 @@ "@react-aria/ssr": "^3.9.10", "@react-aria/utils": "^3.33.0", "@react-aria/visually-hidden": "^3.8.30", + "@react-stately/flags": "^3.1.2", "@react-stately/overlays": "^3.6.22", "@react-types/button": "^3.15.0", "@react-types/overlays": "^3.9.3", diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 763c376ced6..ad0bdeaee33 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -10,7 +10,9 @@ * governing permissions and limitations under the License. */ -import {getOwnerWindow, nodeContains} from '@react-aria/utils'; +import {createShadowTreeWalker, getOwnerDocument, getOwnerWindow, nodeContains} from '@react-aria/utils'; +import {shadowDOM} from '@react-stately/flags'; + const supportsInert = typeof HTMLElement !== 'undefined' && 'inert' in HTMLElement.prototype; interface AriaHideOutsideOptions { @@ -64,6 +66,22 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt } }; + let shadowRootsToWatch = new Set(); + if (shadowDOM()) { + // find all shadow roots that are ancestors of the targets + // traverse upwards until the root is reached + for (let target of targets) { + let node = target; + while (node && node !== root) { + let root = node.getRootNode(); + if ('shadowRoot' in root) { + shadowRootsToWatch.add(root.shadowRoot as ShadowRoot); + } + node = root.parentNode as Element; + } + } + } + let walk = (root: Element) => { // Keep live announcer and top layer elements (e.g. toasts) visible. 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 return NodeFilter.FILTER_ACCEPT; }; - let walker = document.createTreeWalker( + let walker = createShadowTreeWalker( + getOwnerDocument(root), root, NodeFilter.SHOW_ELEMENT, {acceptNode} @@ -164,10 +183,65 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt } } } + + if (shadowDOM()) { + // if any of the observed shadow roots were removed, stop observing them + for (let shadowRoot of shadowRootsToWatch) { + if (!shadowRoot.isConnected) { + observer.disconnect(); + break; + } + } + } } }); observer.observe(root, {childList: true, subtree: true}); + let shadowObservers = new Set(); + if (shadowDOM()) { + for (let shadowRoot of shadowRootsToWatch) { + // Disconnect single target instead of all https://github.com/whatwg/dom/issues/126 + let shadowObserver = new MutationObserver(changes => { + for (let change of changes) { + if (change.type !== 'childList') { + continue; + } + + // If the parent element of the added nodes is not within one of the targets, + // and not already inside a hidden node, hide all of the new children. + if ( + change.target.isConnected && + ![...visibleNodes, ...hiddenNodes].some((node) => + nodeContains(node, change.target) + ) + ) { + for (let node of change.addedNodes) { + if ( + (node instanceof HTMLElement || node instanceof SVGElement) && + (node.dataset.liveAnnouncer === 'true' || node.dataset.reactAriaTopLayer === 'true') + ) { + visibleNodes.add(node); + } else if (node instanceof Element) { + walk(node); + } + } + } + + if (shadowDOM()) { + // if any of the observed shadow roots were removed, stop observing them + for (let shadowRoot of shadowRootsToWatch) { + if (!shadowRoot.isConnected) { + observer.disconnect(); + break; + } + } + } + } + }); + shadowObserver.observe(shadowRoot, {childList: true, subtree: true}); + shadowObservers.add(shadowObserver); + } + } let observerWrapper: ObserverWrapper = { visibleNodes, @@ -184,6 +258,11 @@ export function ariaHideOutside(targets: Element[], options?: AriaHideOutsideOpt return (): void => { observer.disconnect(); + if (shadowDOM()) { + for (let shadowObserver of shadowObservers) { + shadowObserver.disconnect(); + } + } for (let node of hiddenNodes) { let count = refCountMap.get(node); diff --git a/packages/@react-aria/overlays/test/ariaHideOutside.test.js b/packages/@react-aria/overlays/test/ariaHideOutside.test.js index 36f921002b5..a0c8bd2d198 100644 --- a/packages/@react-aria/overlays/test/ariaHideOutside.test.js +++ b/packages/@react-aria/overlays/test/ariaHideOutside.test.js @@ -10,9 +10,12 @@ * governing permissions and limitations under the License. */ -import {act, render, waitFor} from '@react-spectrum/test-utils-internal'; +import {act, createShadowRoot, render, waitFor} from '@react-spectrum/test-utils-internal'; import {ariaHideOutside} from '../src'; +import {enableShadowDOM} from '@react-stately/flags'; import React, {useRef, useState} from 'react'; +import ReactDOM from 'react-dom'; +import {screen} from 'shadow-dom-testing-library'; describe('ariaHideOutside', function () { it('should hide everything except the provided element [button]', function () { @@ -506,3 +509,369 @@ describe('ariaHideOutside', function () { expect(row.parentElement).not.toHaveAttribute('aria-hidden', 'true'); }); }); + +describe('ariaHideOutside with shadow DOM', function () { + beforeAll(() => { + enableShadowDOM(); + }); + + it('should hide everything except the provided element [button]', function () { + const {shadowRoot, shadowHost, cleanup} = createShadowRoot(); + let Wrapper = () => ReactDOM.createPortal( +
+ + + +
, + shadowRoot + ); + render(); + + let button = screen.getByShadowRole('button'); + let checkboxes = screen.getAllByShadowRole('checkbox'); + let revert = ariaHideOutside([button]); + + expect(checkboxes[0]).toHaveAttribute('aria-hidden', 'true'); + expect(checkboxes[1]).toHaveAttribute('aria-hidden', 'true'); + expect(button).not.toHaveAttribute('aria-hidden'); + expect(shadowHost).not.toHaveAttribute('aria-hidden'); + + revert(); + cleanup(); + }); + + function isEffectivelyHidden(element) { + while (element && element.getAttribute) { + const ariaHidden = element.getAttribute('aria-hidden'); + if (ariaHidden === 'true') { + return true; + } else if (ariaHidden === 'false') { + return false; + } + const rootNode = element.getRootNode ? element.getRootNode() : document; + element = element.parentNode || (rootNode !== document ? rootNode.host : null); + } + return false; + } + + describe('ariaHideOutside with Shadow DOM', () => { + let cleanup; + afterEach(() => { + cleanup(); + // iterate over anything leftover in the body and remove it + for (let element of document.body.children) { + document.body.removeChild(element); + } + }); + + it('should not apply aria-hidden to direct parents of the shadow root', () => { + const div1 = document.createElement('div'); + div1.id = 'parent1'; + const div2 = document.createElement('div'); + div2.id = 'parent2'; + div1.appendChild(div2); + document.body.appendChild(div1); + + const shadowRoot = div2.attachShadow({mode: 'open'}); + const ExampleModal = () => ReactDOM.createPortal( + <> + + , + shadowRoot + ); + render(); + + cleanup = ariaHideOutside([shadowRoot.getElementById('modal')], shadowRoot); + + expect(isEffectivelyHidden(document.getElementById('parent1'))).toBeFalsy(); + expect(isEffectivelyHidden(document.getElementById('parent2'))).toBeFalsy(); + expect(isEffectivelyHidden(document.body)).toBeFalsy(); + + expect(isEffectivelyHidden(shadowRoot.getElementById('modal'))).toBeFalsy(); + }); + + it('should correctly apply aria-hidden based on shadow DOM structure', () => { + const div1 = document.createElement('div'); + div1.id = 'parent1'; + const div2 = document.createElement('div'); + div2.id = 'parent2'; + div1.appendChild(div2); + document.body.appendChild(div1); + + const shadowRoot = div2.attachShadow({mode: 'open'}); + shadowRoot.innerHTML = ''; + + shadowRoot.innerHTML += '
Inside Shadow Content
'; + + const outsideContent = document.createElement('div'); + outsideContent.id = 'outsideContent'; + outsideContent.textContent = 'Outside Content'; + document.body.appendChild(outsideContent); + + cleanup = ariaHideOutside([shadowRoot.getElementById('modal')], shadowRoot); + + expect(isEffectivelyHidden(div1)).toBeFalsy(); + expect(isEffectivelyHidden(div2)).toBeFalsy(); + + expect(isEffectivelyHidden(shadowRoot.querySelector('#insideContent'))).toBe(true); + + expect(isEffectivelyHidden(shadowRoot.querySelector('#modal'))).toBeFalsy(); + + expect(isEffectivelyHidden(outsideContent)).toBe(true); + + expect(isEffectivelyHidden(document.body)).toBeFalsy(); + }); + + it('should hide non-direct parent elements like header when modal is in Shadow DOM', () => { + const header = document.createElement('header'); + header.id = 'header'; + document.body.appendChild(header); + + const div1 = document.createElement('div'); + div1.id = 'parent1'; + const div2 = document.createElement('div'); + div2.id = 'parent2'; + div1.appendChild(div2); + document.body.appendChild(div1); + + const shadowRoot = div2.attachShadow({mode: 'open'}); + const modal = document.createElement('div'); + modal.id = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.textContent = 'Modal Content'; + shadowRoot.appendChild(modal); + + cleanup = ariaHideOutside([modal]); + + expect(isEffectivelyHidden(header)).toBe(true); + + expect(isEffectivelyHidden(div1)).toBe(false); + expect(isEffectivelyHidden(div2)).toBe(false); + + expect(isEffectivelyHidden(modal)).toBe(false); + + document.body.removeChild(header); + document.body.removeChild(div1); + }); + + it('should handle a modal inside nested Shadow DOM structures and hide sibling content in the outer shadow root', () => { + const outerDiv = document.createElement('div'); + document.body.appendChild(outerDiv); + const outerShadowRoot = outerDiv.attachShadow({mode: 'open'}); + const innerDiv = document.createElement('div'); + outerShadowRoot.appendChild(innerDiv); + const innerShadowRoot = innerDiv.attachShadow({mode: 'open'}); + + const modal = document.createElement('div'); + modal.setAttribute('role', 'dialog'); + modal.textContent = 'Modal Content'; + innerShadowRoot.appendChild(modal); + + const outsideContent = document.createElement('div'); + outsideContent.textContent = 'Outside Content'; + document.body.appendChild(outsideContent); + + const siblingContent = document.createElement('div'); + siblingContent.textContent = 'Sibling Content'; + outerShadowRoot.appendChild(siblingContent); + + ariaHideOutside([modal], innerShadowRoot); + + expect(isEffectivelyHidden(modal)).toBe(false); + + expect(isEffectivelyHidden(outsideContent)).toBe(true); + + expect(isEffectivelyHidden(siblingContent)).toBe(true); + + document.body.removeChild(outerDiv); + document.body.removeChild(outsideContent); + }); + + it('should handle a modal inside deeply nested Shadow DOM structures', async () => { + // Create a deep nested shadow DOM structure + const createNestedShadowRoot = (depth, currentDepth = 0) => { + const div = document.createElement('div'); + if (currentDepth < depth) { + const shadowRoot = div.attachShadow({mode: 'open'}); + shadowRoot.appendChild(createNestedShadowRoot(depth, currentDepth + 1)); + } else { + div.innerHTML = ''; + } + return div; + }; + + const nestedShadowRootContainer = createNestedShadowRoot(3); // Adjust the depth as needed + document.body.appendChild(nestedShadowRootContainer); + + // Get the deepest shadow root + const getDeepestShadowRoot = (node) => { + while (node.shadowRoot) { + node = node.shadowRoot.childNodes[0]; + } + return node; + }; + + const deepestElement = getDeepestShadowRoot(nestedShadowRootContainer); + const modal = deepestElement.querySelector('#modal'); + + // Apply ariaHideOutside + cleanup = ariaHideOutside([modal]); + + // Check visibility + expect(modal.getAttribute('aria-hidden')).toBeNull(); + expect(isEffectivelyHidden(modal)).toBeFalsy(); + + // Add checks for other elements as needed to ensure correct `aria-hidden` application + }); + + it('should handle dynamic content added to the shadow DOM after ariaHideOutside is applied', async () => { + // This test checks if the MutationObserver logic within ariaHideOutside correctly handles new elements added to the shadow DOM + const div1 = document.createElement('div'); + div1.id = 'parent1'; + document.body.appendChild(div1); + + const shadowRoot = div1.attachShadow({mode: 'open'}); + let ExampleDynamicContent = ({showExtraContent}) => ReactDOM.createPortal( + <> + + {showExtraContent &&
Extra Content
} + , + shadowRoot + ); + + render(); + + // Apply ariaHideOutside + cleanup = ariaHideOutside([shadowRoot.getElementById('modal')]); + + // Dynamically update the content inside the Shadow DOM + render(); + + // Ideally, use a utility function to wait for the MutationObserver callback to run, then check expectations + await waitForMutationObserver(); + + // Expectations + expect(shadowRoot.getElementById('extraContent').getAttribute('aria-hidden')).toBe('true'); + }); + }); + + function waitForMutationObserver() { + return new Promise(resolve => setTimeout(resolve, 0)); + } + + describe('ariaHideOutside with nested Shadow DOMs', () => { + let cleanup; + afterEach(() => { + cleanup(); + // iterate over anything leftover in the body and remove it + for (let element of document.body.children) { + document.body.removeChild(element); + } + }); + + it('should hide appropriate elements including those in nested shadow roots without targets', () => { + // Set up the initial DOM with shadow hosts and content. + document.body.innerHTML = ` +
+
+
+
+
+
+
+
+ `; + + // Create the first shadow root for C3 and append children to it. + const shadowHostC3 = document.querySelector('#C3'); + const shadowRootC3 = shadowHostC3.attachShadow({mode: 'open'}); + shadowRootC3.innerHTML = ` +
Inner Content C3-1
+
Inner Content C3-2
+ `; + + // Create the second shadow root for C4 and append children to it. + const shadowHostC4 = document.querySelector('#C4'); + const shadowRootC4 = shadowHostC4.attachShadow({mode: 'open'}); + shadowRootC4.innerHTML = ` +
+
+ `; + + // Create a nested shadow root inside C6 and append a modal element to it. + const divC6 = shadowRootC4.querySelector('#C6'); + const shadowRootC6 = divC6.attachShadow({mode: 'open'}); + shadowRootC6.innerHTML = ` + + `; + + // Execute ariaHideOutside targeting the modal and C1. + const modalElement = shadowRootC6.querySelector('#modal'); + const c1Element = document.querySelector('#C1'); + cleanup = ariaHideOutside([modalElement, c1Element]); + + // Assertions to check the visibility + expect(c1Element.getAttribute('aria-hidden')).toBeNull(); + expect(modalElement.getAttribute('aria-hidden')).toBeNull(); + + // Parents of the modal and C1 should be visible + expect(shadowHostC4.getAttribute('aria-hidden')).toBeNull(); + expect(document.getElementById('P1').getAttribute('aria-hidden')).toBeNull(); + expect(document.getElementById('P2').getAttribute('aria-hidden')).toBeNull(); + + // Siblings and other elements should be hidden + expect(document.getElementById('C2').getAttribute('aria-hidden')).toBe('true'); + expect(shadowHostC3.getAttribute('aria-hidden')).toBe('true'); + expect(shadowRootC4.querySelector('#C5').getAttribute('aria-hidden')).toBe('true'); + }); + + it('should handle input and popup pattern in shadow DOM', () => { + // Set up the initial DOM with shadow hosts and content + document.body.innerHTML = ` +
+
+
+
+
+
+
+
+ `; + + // Create a shadow root that will contain both our input and overlay + const shadowHostC4 = document.querySelector('#C4'); + const shadowRootC4 = shadowHostC4.attachShadow({mode: 'open'}); + shadowRootC4.innerHTML = ` +
+
+ +
+
+ +
+ `; + + // Get our target elements (input and popup) that should remain visible + const inputElement = shadowRootC4.querySelector('#input'); + const popupElement = shadowRootC4.querySelector('#popup'); + + // Call ariaHideOutside with both the input and popup as targets + cleanup = ariaHideOutside([inputElement, popupElement]); + + // Input and popup should remain visible + expect(inputElement.getAttribute('aria-hidden')).toBeNull(); + expect(popupElement.getAttribute('aria-hidden')).toBeNull(); + + // Their direct containers should remain visible + expect(shadowRootC4.querySelector('.content-container').getAttribute('aria-hidden')).toBeNull(); + expect(shadowRootC4.querySelector('.overlay-portal').getAttribute('aria-hidden')).toBeNull(); + + // The unrelated container should be hidden + expect(shadowRootC4.querySelector('.content-container-2').getAttribute('aria-hidden')).toBe('true'); + + // Shadow host and its parent should be visible since they contain our targets + expect(shadowHostC4.getAttribute('aria-hidden')).toBeNull(); + expect(document.getElementById('P2').getAttribute('aria-hidden')).toBeNull(); + }); + }); +}); diff --git a/packages/@react-aria/utils/test/DOMFunctions.test.js b/packages/@react-aria/utils/test/DOMFunctions.test.js new file mode 100644 index 00000000000..b7cf7a4b984 --- /dev/null +++ b/packages/@react-aria/utils/test/DOMFunctions.test.js @@ -0,0 +1,45 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {createShadowRoot, render} from '@react-spectrum/test-utils-internal'; +import {enableShadowDOM} from '@react-stately/flags'; +import {nodeContains} from '..'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import {screen} from 'shadow-dom-testing-library'; + +describe('nodeContains with shadow DOM', function () { + beforeAll(() => { + enableShadowDOM(); + }); + + it('can tell if a node is contained even if it is within a shadow DOM', function () { + const {shadowRoot, shadowHost, cleanup} = createShadowRoot(); + let Wrapper = () => ReactDOM.createPortal( +
+ + + +
, + shadowRoot + ); + render(); + + let button = screen.getByShadowRole('button'); + + expect(nodeContains(shadowRoot, button)).toBe(true); + expect(nodeContains(shadowHost, button)).toBe(true); + + cleanup(); + }); +}); + diff --git a/packages/dev/test-utils/src/shadowDOM.ts b/packages/dev/test-utils/src/shadowDOM.ts index ee41da0f5d0..8e9858aa0f0 100644 --- a/packages/dev/test-utils/src/shadowDOM.ts +++ b/packages/dev/test-utils/src/shadowDOM.ts @@ -18,6 +18,7 @@ interface ShadowRootReturnValue { export function createShadowRoot(attachTo: HTMLElement = document.body): ShadowRootReturnValue { const div = document.createElement('div'); + div.setAttribute('data-testid', 'shadow-root'); attachTo.appendChild(div); const shadowRoot = div.attachShadow({mode: 'open'}); return {shadowHost: div, shadowRoot, cleanup: () => attachTo.removeChild(div)}; diff --git a/yarn.lock b/yarn.lock index 32a4f9f2661..86f158ce9be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5910,6 +5910,7 @@ __metadata: "@react-aria/ssr": "npm:^3.9.10" "@react-aria/utils": "npm:^3.33.0" "@react-aria/visually-hidden": "npm:^3.8.30" + "@react-stately/flags": "npm:^3.1.2" "@react-stately/overlays": "npm:^3.6.22" "@react-types/button": "npm:^3.15.0" "@react-types/overlays": "npm:^3.9.3" @@ -8989,17 +8990,10 @@ __metadata: languageName: node linkType: hard -"@sinclair/typebox@npm:^0.24.1": - version: 0.24.51 - resolution: "@sinclair/typebox@npm:0.24.51" - checksum: 10c0/458131e83ca59ad3721f0abeef2aa5220aff2083767e1143d75c67c85d55ef7a212f48f394471ee6bdd2e860ba30f09a489cdd2a28a2824d5b0d1014bdfb2552 - languageName: node - linkType: hard - -"@sinclair/typebox@npm:^0.27.8": - version: 0.27.8 - resolution: "@sinclair/typebox@npm:0.27.8" - checksum: 10c0/ef6351ae073c45c2ac89494dbb3e1f87cc60a93ce4cde797b782812b6f97da0d620ae81973f104b43c9b7eaa789ad20ba4f6a1359f1cc62f63729a55a7d22d4e +"@sinclair/typebox@npm:0.27.10": + version: 0.27.10 + resolution: "@sinclair/typebox@npm:0.27.10" + checksum: 10c0/ca42a02817656dbdae464ed4bb8aca6ad4718d7618e270760fea84a834ad0ecc1a22eba51421f09e5047174571131356ff3b5d80d609ced775d631df7b404b0d languageName: node linkType: hard @@ -24956,6 +24950,7 @@ __metadata: regenerator-runtime: "npm:0.13.3" rehype-stringify: "npm:^9.0.4" rimraf: "npm:^6.0.1" + shadow-dom-testing-library: "npm:^1.13.1" sharp: "npm:^0.33.5" storybook: "npm:^8.6.14" storybook-dark-mode: "npm:^4.0.2" @@ -26220,6 +26215,15 @@ __metadata: languageName: node linkType: hard +"shadow-dom-testing-library@npm:^1.13.1": + version: 1.13.1 + resolution: "shadow-dom-testing-library@npm:1.13.1" + peerDependencies: + "@testing-library/dom": ">= 8" + checksum: 10c0/cd0a5e7799f868af665235d0812bdbcfbfe4461681ef35ce0fba4d460d395f3fa0e95df5c8fec4686ba30286a62c4e7ba48013e67646977726aa13363479d70f + languageName: node + linkType: hard + "shallow-clone@npm:^3.0.0": version: 3.0.1 resolution: "shallow-clone@npm:3.0.1"