diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 791aa9b6e8b..296a32b748a 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -10,11 +10,18 @@ * governing permissions and limitations under the License. */ -// Keeps a ref count of all hidden elements. Added to when hiding an element, and -// subtracted from when showing it again. When it reaches zero, aria-hidden is removed. let refCountMap = new WeakMap(); let observerStack = []; +function isInShadowDOM(node) { + return node.getRootNode() instanceof ShadowRoot; +} + +// Function to find the shadow root, if any, in the targets +function findShadowRoots(targets) { + return targets.filter(target => isInShadowDOM(target))?.map(target => target.getRootNode()); +} + /** * Hides all elements in the DOM outside the given targets from screen readers using aria-hidden, * and returns a function to revert these changes. In addition, changes to the DOM are watched @@ -26,6 +33,62 @@ let observerStack = []; export function ariaHideOutside(targets: Element[], root = document.body) { let visibleNodes = new Set(targets); let hiddenNodes = new Set(); + const shadowRoots = findShadowRoots(targets); + + if (shadowRoots.length > 0) { + const targetsByShadowRoot = new Map(); + + targets.forEach(target => { + const root = target.getRootNode(); + if (root instanceof ShadowRoot) { + if (!targetsByShadowRoot.has(root)) { + targetsByShadowRoot.set(root, []); + } + targetsByShadowRoot.get(root).push(target); + } else { + // For non-shadow DOM targets, add all ancestors up to document.body + let current = target; + while (current && current !== document.body) { + visibleNodes.add(current); + current = current.parentElement; + } + } + }); + + // Handle targets in each shadow root + targetsByShadowRoot.forEach((groupedTargets, shadowRoot) => { + groupedTargets.forEach(target => { + // Add the target itself + visibleNodes.add(target); + + // Add its parent container within shadow root + if (target.parentElement) { + visibleNodes.add(target.parentElement); + } + + // Walk up until we hit the shadow root's immediate child + let current = target; + while (current && current.parentElement && current.parentElement !== shadowRoot.host) { + visibleNodes.add(current.parentElement); + current = current.parentElement; + } + }); + + // Add the shadow host and its ancestors up to document.body + let host = shadowRoot.host; + while (host && host !== document.body) { + visibleNodes.add(host); + if (host.getRootNode() instanceof ShadowRoot) { + host = (host.getRootNode() as ShadowRoot).host; + } else { + host = host.parentElement; + } + } + }); + + // Always add document.body + visibleNodes.add(document.body); + } let walk = (root: Element) => { // Keep live announcer and top layer elements (e.g. toasts) visible. @@ -79,6 +142,10 @@ export function ariaHideOutside(targets: Element[], root = document.body) { let hide = (node: Element) => { let refCount = refCountMap.get(node) ?? 0; + if (!(node instanceof Element)) { + return; + } + // If already aria-hidden, and the ref count is zero, then this element // was already hidden and there's nothing for us to do. if (node.getAttribute('aria-hidden') === 'true' && refCount === 0) { @@ -93,6 +160,34 @@ export function ariaHideOutside(targets: Element[], root = document.body) { refCountMap.set(node, refCount + 1); }; + // Function to hide an element's siblings + const hideSiblings = (element: Element) => { + let parentNode = element.parentNode; + if (parentNode) { + parentNode.childNodes.forEach((sibling: Element) => { + if (sibling !== element && !visibleNodes.has(sibling) && !hiddenNodes.has(sibling)) { + hide(sibling); + } + }); + } + }; + + if (shadowRoots.length > 0) { + targets.forEach(target => { + let current = target; + // Process up to and including the body element + while (current && current !== document.body) { + hideSiblings(current); + if (current.parentNode instanceof ShadowRoot) { + current = current.parentNode.host; + } else { + // Otherwise, just move to the parent node + current = current.parentNode as Element; + } + } + }); + } + // If there is already a MutationObserver listening from a previous call, // disconnect it so the new on takes over. if (observerStack.length) { @@ -131,6 +226,12 @@ export function ariaHideOutside(targets: Element[], root = document.body) { } }); + if (shadowRoots.length > 0) { + shadowRoots.forEach(shadowRoot => { + observer.observe(shadowRoot, {childList: true, subtree: true}); + }); + } + observer.observe(root, {childList: true, subtree: true}); let observerWrapper = { @@ -147,6 +248,12 @@ export function ariaHideOutside(targets: Element[], root = document.body) { return () => { observer.disconnect(); + if (shadowRoots.length > 0) { + shadowRoots.forEach(() => { + observer.disconnect(); + }); + } + for (let node of hiddenNodes) { let count = refCountMap.get(node); if (count === 1) { diff --git a/packages/@react-aria/overlays/test/ariaHideOutside.test.js b/packages/@react-aria/overlays/test/ariaHideOutside.test.js index 2a9f610f4c2..3696ae79c59 100644 --- a/packages/@react-aria/overlays/test/ariaHideOutside.test.js +++ b/packages/@react-aria/overlays/test/ariaHideOutside.test.js @@ -12,6 +12,7 @@ import {ariaHideOutside} from '../src'; import React from 'react'; +import ReactDOM from 'react-dom'; import {render, waitFor} from '@react-spectrum/test-utils-internal'; describe('ariaHideOutside', function () { @@ -384,4 +385,319 @@ describe('ariaHideOutside', function () { expect(rows[1]).not.toHaveAttribute('aria-hidden', 'true'); expect(cells[1]).not.toHaveAttribute('aria-hidden', 'true'); }); + + 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', () => { + 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.render(, shadowRoot); + + 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); + + 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); + + 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 + 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}) => ( + <> + + {showExtraContent &&
Extra Content
} + + ); + + ReactDOM.render(, shadowRoot); + + // Apply ariaHideOutside + ariaHideOutside([shadowRoot.getElementById('modal')]); + + // Dynamically update the content inside the Shadow DOM + ReactDOM.render(, shadowRoot); + + // 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', () => { + 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'); + 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 + 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(); + }); + }); });