From c2f54c1e26242464388bfd63dce7dd55911cf0bf Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 5 Apr 2024 21:41:00 +0200 Subject: [PATCH 01/11] Initial commit to test out the solution. --- .../overlays/src/ariaHideOutside.ts | 95 ++++++- .../overlays/test/ariaHideOutside.test.js | 236 ++++++++++++++++++ 2 files changed, 329 insertions(+), 2 deletions(-) diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 791aa9b6e8b..38f1d9970db 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -15,6 +15,44 @@ 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 findShadowRoot(targets) { + return targets.find(target => isInShadowDOM(target))?.getRootNode(); +} + +function hideSiblings(node, hide) { + let parent = node.parentNode; + let sibling = parent.firstChild; + while (sibling) { + if (sibling !== node && sibling instanceof Element) { + hide(sibling); + } + sibling = sibling.nextSibling; + } +} + +function hideOuterSiblings(node, shadowRoot, visibleNodes, hide) { + if (node === document.body || visibleNodes.has(node)) { + return; + } + + // Hide siblings of the node unless it's a shadow root host + if (!(node instanceof ShadowRoot) && node.parentNode) { + hideSiblings(node, hide); + } + + // If the node has a shadow root, recursively call this function on its host + if (node instanceof ShadowRoot && node.host) { + hideOuterSiblings(node.host, shadowRoot, visibleNodes, hide); + } else if (node.parentNode) { + hideOuterSiblings(node.parentNode, shadowRoot, visibleNodes, hide); + } +} + /** * 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 @@ -27,6 +65,19 @@ export function ariaHideOutside(targets: Element[], root = document.body) { let visibleNodes = new Set(targets); let hiddenNodes = new Set(); + // Use a shadowRoot if one of the targets is part of it + const shadowRoot = findShadowRoot(targets); + const inShadowDOM = !!shadowRoot; + + // Include all ancestors of the shadow root host in visibleNodes + if (inShadowDOM) { + let node = shadowRoot.host; + while (node) { + visibleNodes.add(node); + node = node.parentNode || node.host; + } + } + 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,13 +144,53 @@ export function ariaHideOutside(targets: Element[], root = document.body) { refCountMap.set(node, refCount + 1); }; + // New code to handle nested shadow roots + if (shadowRoot) { + hideOuterSiblings(shadowRoot, shadowRoot, visibleNodes, hide); + } + + // If the targets are within a shadow DOM, we need to also hide elements in the light DOM + if (inShadowDOM) { + let hostElement = shadowRoot.host; + let sibling = hostElement.parentNode.firstChild; + while (sibling) { + if (sibling !== hostElement && sibling instanceof Element && !visibleNodes.has(sibling)) { + hide(sibling); + } + sibling = sibling.nextSibling; + } + } + // If there is already a MutationObserver listening from a previous call, // disconnect it so the new on takes over. if (observerStack.length) { observerStack[observerStack.length - 1].disconnect(); } - walk(root); + // If the targets are within a shadow DOM, we need to also hide elements in the light DOM + if (inShadowDOM) { + let outerWalker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: function (node) { + // Accept nodes that are not ancestors of the shadow host + // and not within the shadow DOM itself + if (visibleNodes.has(node) || isInShadowDOM(node)) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + } + ); + + let node; + while ((node = outerWalker.nextNode())) { + hide(node); + } + } + + walk(inShadowDOM ? shadowRoot : root); let observer = new MutationObserver(changes => { for (let change of changes) { @@ -131,7 +222,7 @@ export function ariaHideOutside(targets: Element[], root = document.body) { } }); - observer.observe(root, {childList: true, subtree: true}); + observer.observe(inShadowDOM ? shadowRoot : root, {childList: true, subtree: true}); let observerWrapper = { observe() { diff --git a/packages/@react-aria/overlays/test/ariaHideOutside.test.js b/packages/@react-aria/overlays/test/ariaHideOutside.test.js index 44434ba32fd..13955545af4 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'; describe('ariaHideOutside', function () { @@ -384,4 +385,239 @@ 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 correctly apply aria-hidden when the modal is in the outer shadow root and an inner shadow root does not contain the modal', async () => { + // // Setup outer shadow root with modal + // const outerDiv = document.createElement('div'); + // const outerShadowRoot = outerDiv.attachShadow({ mode: 'open' }); + // const modal = document.createElement('div'); + // modal.id = 'modal'; + // modal.setAttribute('role', 'dialog'); + // modal.textContent = 'Modal Content'; + // outerShadowRoot.appendChild(modal); + // + // // Setup inner shadow root without modal + // const innerDiv = document.createElement('div'); + // const innerShadowRoot = innerDiv.attachShadow({ mode: 'open' }); + // innerShadowRoot.innerHTML = '
Inner Shadow Content
'; + // outerShadowRoot.appendChild(innerDiv); + // + // document.body.appendChild(outerDiv); + // + // // Apply ariaHideOutside targeting the modal in the outer shadow root + // ariaHideOutside([modal]); + // + // // Expectations + // expect(isEffectivelyHidden(modal)).toBeFalsy(); + // expect(isEffectivelyHidden(innerShadowRoot.querySelector('#innerContent'))).toBe(true); + // }); + + 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)); + } + }); From 851e66366705ca8e51dad0988f6fb853f654f0f4 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 5 Apr 2024 22:19:26 +0200 Subject: [PATCH 02/11] Skip ts error for now. --- packages/@react-aria/overlays/src/ariaHideOutside.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 38f1d9970db..294c95a3383 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -176,6 +176,7 @@ export function ariaHideOutside(targets: Element[], root = document.body) { acceptNode: function (node) { // Accept nodes that are not ancestors of the shadow host // and not within the shadow DOM itself + // @ts-expect-error if (visibleNodes.has(node) || isInShadowDOM(node)) { return NodeFilter.FILTER_REJECT; } From 380db8fdd6101f57b1a76afa0364ea8fa29b6333 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Sat, 6 Apr 2024 19:56:36 +0200 Subject: [PATCH 03/11] Update the fix. --- .../overlays/src/ariaHideOutside.ts | 135 +++++++----------- .../overlays/test/ariaHideOutside.test.js | 51 +++---- 2 files changed, 79 insertions(+), 107 deletions(-) diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 294c95a3383..8916cfd0965 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -20,37 +20,8 @@ function isInShadowDOM(node) { } // Function to find the shadow root, if any, in the targets -function findShadowRoot(targets) { - return targets.find(target => isInShadowDOM(target))?.getRootNode(); -} - -function hideSiblings(node, hide) { - let parent = node.parentNode; - let sibling = parent.firstChild; - while (sibling) { - if (sibling !== node && sibling instanceof Element) { - hide(sibling); - } - sibling = sibling.nextSibling; - } -} - -function hideOuterSiblings(node, shadowRoot, visibleNodes, hide) { - if (node === document.body || visibleNodes.has(node)) { - return; - } - - // Hide siblings of the node unless it's a shadow root host - if (!(node instanceof ShadowRoot) && node.parentNode) { - hideSiblings(node, hide); - } - - // If the node has a shadow root, recursively call this function on its host - if (node instanceof ShadowRoot && node.host) { - hideOuterSiblings(node.host, shadowRoot, visibleNodes, hide); - } else if (node.parentNode) { - hideOuterSiblings(node.parentNode, shadowRoot, visibleNodes, hide); - } +function findShadowRoots(targets) { + return targets.filter(target => isInShadowDOM(target))?.map(target => target.getRootNode()); } /** @@ -64,19 +35,21 @@ function hideOuterSiblings(node, shadowRoot, visibleNodes, hide) { export function ariaHideOutside(targets: Element[], root = document.body) { let visibleNodes = new Set(targets); let hiddenNodes = new Set(); - - // Use a shadowRoot if one of the targets is part of it - const shadowRoot = findShadowRoot(targets); - const inShadowDOM = !!shadowRoot; - - // Include all ancestors of the shadow root host in visibleNodes - if (inShadowDOM) { - let node = shadowRoot.host; - while (node) { - visibleNodes.add(node); - node = node.parentNode || node.host; + const shadowRoots = findShadowRoots(targets); + + // Add all ancestors of each target to the set of visible nodes to ensure they are not hidden + targets.forEach(target => { + let current = target; + while (current && current !== document.body) { + visibleNodes.add(current); + if (current.getRootNode() instanceof ShadowRoot) { + // If within a shadow DOM, add the host element + current = (current.getRootNode() as ShadowRoot).host; + } else { + current = current.parentNode as Element; + } } - } + }); let walk = (root: Element) => { // Keep live announcer and top layer elements (e.g. toasts) visible. @@ -144,22 +117,33 @@ export function ariaHideOutside(targets: Element[], root = document.body) { refCountMap.set(node, refCount + 1); }; - // New code to handle nested shadow roots - if (shadowRoot) { - hideOuterSiblings(shadowRoot, shadowRoot, visibleNodes, hide); - } + // Function to hide an element's siblings + const hideSiblings = (element: Element) => { + let parentNode = element.parentNode; + if (parentNode) { + parentNode.childNodes.forEach(sibling => { + if (sibling !== element && sibling instanceof Element && !visibleNodes.has(sibling) && !hiddenNodes.has(sibling)) { + hide(sibling); + } + }); + } + }; - // If the targets are within a shadow DOM, we need to also hide elements in the light DOM - if (inShadowDOM) { - let hostElement = shadowRoot.host; - let sibling = hostElement.parentNode.firstChild; - while (sibling) { - if (sibling !== hostElement && sibling instanceof Element && !visibleNodes.has(sibling)) { - hide(sibling); + // Main function to process each target element + targets.forEach(target => { + let current = target; + // Process up to and including the body element + while (current && current !== document.body) { + hideSiblings(current); + // Move to the host element if current is within a shadow DOM + if (current.getRootNode() instanceof ShadowRoot) { + current = (current.getRootNode() as ShadowRoot).host; + } else { + // Otherwise, just move to the parent node + current = current.parentNode as Element; } - sibling = sibling.nextSibling; } - } + }); // If there is already a MutationObserver listening from a previous call, // disconnect it so the new on takes over. @@ -167,31 +151,7 @@ export function ariaHideOutside(targets: Element[], root = document.body) { observerStack[observerStack.length - 1].disconnect(); } - // If the targets are within a shadow DOM, we need to also hide elements in the light DOM - if (inShadowDOM) { - let outerWalker = document.createTreeWalker( - document.body, - NodeFilter.SHOW_ELEMENT, - { - acceptNode: function (node) { - // Accept nodes that are not ancestors of the shadow host - // and not within the shadow DOM itself - // @ts-expect-error - if (visibleNodes.has(node) || isInShadowDOM(node)) { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - } - } - ); - - let node; - while ((node = outerWalker.nextNode())) { - hide(node); - } - } - - walk(inShadowDOM ? shadowRoot : root); + walk(root); let observer = new MutationObserver(changes => { for (let change of changes) { @@ -223,7 +183,13 @@ export function ariaHideOutside(targets: Element[], root = document.body) { } }); - observer.observe(inShadowDOM ? shadowRoot : root, {childList: true, subtree: true}); + if (shadowRoots) { + shadowRoots.forEach(shadowRoot => { + observer.observe(shadowRoot, {childList: true, subtree: true}); + }); + } else { + observer.observe(root, {childList: true, subtree: true}); + } let observerWrapper = { observe() { @@ -239,6 +205,11 @@ export function ariaHideOutside(targets: Element[], root = document.body) { return () => { observer.disconnect(); + // Function to revert the aria-hidden attribute + hiddenNodes.forEach(node => { + node.removeAttribute('aria-hidden'); + }); + 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 13955545af4..1c41bc4ec93 100644 --- a/packages/@react-aria/overlays/test/ariaHideOutside.test.js +++ b/packages/@react-aria/overlays/test/ariaHideOutside.test.js @@ -560,31 +560,32 @@ describe('ariaHideOutside', function () { // Add checks for other elements as needed to ensure correct `aria-hidden` application }); - // it('should correctly apply aria-hidden when the modal is in the outer shadow root and an inner shadow root does not contain the modal', async () => { - // // Setup outer shadow root with modal - // const outerDiv = document.createElement('div'); - // const outerShadowRoot = outerDiv.attachShadow({ mode: 'open' }); - // const modal = document.createElement('div'); - // modal.id = 'modal'; - // modal.setAttribute('role', 'dialog'); - // modal.textContent = 'Modal Content'; - // outerShadowRoot.appendChild(modal); - // - // // Setup inner shadow root without modal - // const innerDiv = document.createElement('div'); - // const innerShadowRoot = innerDiv.attachShadow({ mode: 'open' }); - // innerShadowRoot.innerHTML = '
Inner Shadow Content
'; - // outerShadowRoot.appendChild(innerDiv); - // - // document.body.appendChild(outerDiv); - // - // // Apply ariaHideOutside targeting the modal in the outer shadow root - // ariaHideOutside([modal]); - // - // // Expectations - // expect(isEffectivelyHidden(modal)).toBeFalsy(); - // expect(isEffectivelyHidden(innerShadowRoot.querySelector('#innerContent'))).toBe(true); - // }); + it('should correctly apply aria-hidden when the modal is in the outer shadow root and an inner shadow root does not contain the modal', async () => { + // Setup outer shadow root with modal + const outerDiv = document.createElement('div'); + const outerShadowRoot = outerDiv.attachShadow({ mode: 'open' }); + const modal = document.createElement('div'); + modal.id = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.textContent = 'Modal Content'; + outerShadowRoot.appendChild(modal); + + // Setup inner shadow root without modal + const innerDiv = document.createElement('div'); + const innerShadowRoot = innerDiv.attachShadow({ mode: 'open' }); + innerShadowRoot.innerHTML = '
Inner Shadow Content
'; + outerShadowRoot.appendChild(innerDiv); + + document.body.appendChild(outerDiv); + + // Apply ariaHideOutside targeting the modal in the outer shadow root + ariaHideOutside([modal]); + + // Expectations + expect(isEffectivelyHidden(modal)).toBeFalsy(); + console.log(innerShadowRoot.querySelector('#innerContent')); + expect(isEffectivelyHidden(innerShadowRoot.querySelector('#innerContent'))).toBe(true); + }); 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 From 0631e99558de246329b4218fe6ef45cea6499cef Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Sat, 6 Apr 2024 20:29:14 +0200 Subject: [PATCH 04/11] Skip a test for testing? --- .../dnd/test/useDroppableCollection.test.js | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/@react-aria/dnd/test/useDroppableCollection.test.js b/packages/@react-aria/dnd/test/useDroppableCollection.test.js index ceb8a814894..5f8b979a7d1 100644 --- a/packages/@react-aria/dnd/test/useDroppableCollection.test.js +++ b/packages/@react-aria/dnd/test/useDroppableCollection.test.js @@ -1161,26 +1161,26 @@ describe('useDroppableCollection', () => { expect(document.getElementById(dropIndicator.getAttribute('aria-describedby'))).toHaveTextContent('Click to drop.'); }); - it('should hide items that do not accept the drop', () => { - let tree = render(<> - - - ); - - let draggable = tree.getByText('Drag me'); - let grid = tree.getByRole('grid'); - let cells = within(grid).getAllByRole('gridcell'); - expect(cells).toHaveLength(3); - - fireEvent.focus(draggable); - fireEvent.click(draggable); - act(() => jest.runAllTimers()); - - cells = within(grid).getAllByRole('gridcell', {hidden: true}); - expect(cells).toHaveLength(8); - - expect(cells[4]).toHaveTextContent('Two'); - expect(cells[4]).toHaveAttribute('aria-hidden', 'true'); - }); + // it('should hide items that do not accept the drop', () => { + // let tree = render(<> + // + // + // ); + // + // let draggable = tree.getByText('Drag me'); + // let grid = tree.getByRole('grid'); + // let cells = within(grid).getAllByRole('gridcell'); + // expect(cells).toHaveLength(3); + // + // fireEvent.focus(draggable); + // fireEvent.click(draggable); + // act(() => jest.runAllTimers()); + // + // cells = within(grid).getAllByRole('gridcell', {hidden: true}); + // expect(cells).toHaveLength(8); + // + // expect(cells[4]).toHaveTextContent('Two'); + // expect(cells[4]).toHaveAttribute('aria-hidden', 'true'); + // }); }); }); From 829e1f4aa18acd1400479a0266136ed7c8db34ea Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Sun, 7 Apr 2024 00:42:40 +0200 Subject: [PATCH 05/11] Revert to test CI? --- .../overlays/src/ariaHideOutside.ts | 65 +---- .../overlays/test/ariaHideOutside.test.js | 237 ------------------ 2 files changed, 1 insertion(+), 301 deletions(-) diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 8916cfd0965..791aa9b6e8b 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -15,15 +15,6 @@ 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 @@ -35,21 +26,6 @@ function findShadowRoots(targets) { export function ariaHideOutside(targets: Element[], root = document.body) { let visibleNodes = new Set(targets); let hiddenNodes = new Set(); - const shadowRoots = findShadowRoots(targets); - - // Add all ancestors of each target to the set of visible nodes to ensure they are not hidden - targets.forEach(target => { - let current = target; - while (current && current !== document.body) { - visibleNodes.add(current); - if (current.getRootNode() instanceof ShadowRoot) { - // If within a shadow DOM, add the host element - current = (current.getRootNode() as ShadowRoot).host; - } else { - current = current.parentNode as Element; - } - } - }); let walk = (root: Element) => { // Keep live announcer and top layer elements (e.g. toasts) visible. @@ -117,34 +93,6 @@ 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 => { - if (sibling !== element && sibling instanceof Element && !visibleNodes.has(sibling) && !hiddenNodes.has(sibling)) { - hide(sibling); - } - }); - } - }; - - // Main function to process each target element - targets.forEach(target => { - let current = target; - // Process up to and including the body element - while (current && current !== document.body) { - hideSiblings(current); - // Move to the host element if current is within a shadow DOM - if (current.getRootNode() instanceof ShadowRoot) { - current = (current.getRootNode() as ShadowRoot).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) { @@ -183,13 +131,7 @@ export function ariaHideOutside(targets: Element[], root = document.body) { } }); - if (shadowRoots) { - shadowRoots.forEach(shadowRoot => { - observer.observe(shadowRoot, {childList: true, subtree: true}); - }); - } else { - observer.observe(root, {childList: true, subtree: true}); - } + observer.observe(root, {childList: true, subtree: true}); let observerWrapper = { observe() { @@ -205,11 +147,6 @@ export function ariaHideOutside(targets: Element[], root = document.body) { return () => { observer.disconnect(); - // Function to revert the aria-hidden attribute - hiddenNodes.forEach(node => { - node.removeAttribute('aria-hidden'); - }); - 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 1c41bc4ec93..44434ba32fd 100644 --- a/packages/@react-aria/overlays/test/ariaHideOutside.test.js +++ b/packages/@react-aria/overlays/test/ariaHideOutside.test.js @@ -12,7 +12,6 @@ import {ariaHideOutside} from '../src'; import React from 'react'; -import ReactDOM from 'react-dom'; import {render, waitFor} from '@react-spectrum/test-utils'; describe('ariaHideOutside', function () { @@ -385,240 +384,4 @@ 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 correctly apply aria-hidden when the modal is in the outer shadow root and an inner shadow root does not contain the modal', async () => { - // Setup outer shadow root with modal - const outerDiv = document.createElement('div'); - const outerShadowRoot = outerDiv.attachShadow({ mode: 'open' }); - const modal = document.createElement('div'); - modal.id = 'modal'; - modal.setAttribute('role', 'dialog'); - modal.textContent = 'Modal Content'; - outerShadowRoot.appendChild(modal); - - // Setup inner shadow root without modal - const innerDiv = document.createElement('div'); - const innerShadowRoot = innerDiv.attachShadow({ mode: 'open' }); - innerShadowRoot.innerHTML = '
Inner Shadow Content
'; - outerShadowRoot.appendChild(innerDiv); - - document.body.appendChild(outerDiv); - - // Apply ariaHideOutside targeting the modal in the outer shadow root - ariaHideOutside([modal]); - - // Expectations - expect(isEffectivelyHidden(modal)).toBeFalsy(); - console.log(innerShadowRoot.querySelector('#innerContent')); - expect(isEffectivelyHidden(innerShadowRoot.querySelector('#innerContent'))).toBe(true); - }); - - 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)); - } - }); From df87528e28abdcbda5490a2403b42c588b6af087 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Sun, 7 Apr 2024 00:44:36 +0200 Subject: [PATCH 06/11] Revert to test CI? --- .../dnd/test/useDroppableCollection.test.js | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/@react-aria/dnd/test/useDroppableCollection.test.js b/packages/@react-aria/dnd/test/useDroppableCollection.test.js index 5f8b979a7d1..ceb8a814894 100644 --- a/packages/@react-aria/dnd/test/useDroppableCollection.test.js +++ b/packages/@react-aria/dnd/test/useDroppableCollection.test.js @@ -1161,26 +1161,26 @@ describe('useDroppableCollection', () => { expect(document.getElementById(dropIndicator.getAttribute('aria-describedby'))).toHaveTextContent('Click to drop.'); }); - // it('should hide items that do not accept the drop', () => { - // let tree = render(<> - // - // - // ); - // - // let draggable = tree.getByText('Drag me'); - // let grid = tree.getByRole('grid'); - // let cells = within(grid).getAllByRole('gridcell'); - // expect(cells).toHaveLength(3); - // - // fireEvent.focus(draggable); - // fireEvent.click(draggable); - // act(() => jest.runAllTimers()); - // - // cells = within(grid).getAllByRole('gridcell', {hidden: true}); - // expect(cells).toHaveLength(8); - // - // expect(cells[4]).toHaveTextContent('Two'); - // expect(cells[4]).toHaveAttribute('aria-hidden', 'true'); - // }); + it('should hide items that do not accept the drop', () => { + let tree = render(<> + + + ); + + let draggable = tree.getByText('Drag me'); + let grid = tree.getByRole('grid'); + let cells = within(grid).getAllByRole('gridcell'); + expect(cells).toHaveLength(3); + + fireEvent.focus(draggable); + fireEvent.click(draggable); + act(() => jest.runAllTimers()); + + cells = within(grid).getAllByRole('gridcell', {hidden: true}); + expect(cells).toHaveLength(8); + + expect(cells[4]).toHaveTextContent('Two'); + expect(cells[4]).toHaveAttribute('aria-hidden', 'true'); + }); }); }); From 0378bbedc193f2aff1612c458664e5b9c2ab11b0 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 8 Apr 2024 06:06:14 +0200 Subject: [PATCH 07/11] Almost work --- .../overlays/src/ariaHideOutside.ts | 68 ++++++++++++++++++- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 791aa9b6e8b..2a4f668188b 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -10,11 +10,17 @@ * 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 +32,22 @@ let observerStack = []; export function ariaHideOutside(targets: Element[], root = document.body) { let visibleNodes = new Set(targets); let hiddenNodes = new Set(); + let refCountMap = new WeakMap(); + const shadowRoots = findShadowRoots(targets); + + // Add all ancestors of each target to the set of visible nodes to ensure they are not hidden + targets.forEach(target => { + let current = target; + while (current && current !== document.body) { + visibleNodes.add(current); + if (current.getRootNode() instanceof ShadowRoot) { + // If within a shadow DOM, add the host element + current = (current.getRootNode() as ShadowRoot).host; + } else { + current = current.parentNode as Element; + } + } + }); let walk = (root: Element) => { // Keep live announcer and top layer elements (e.g. toasts) visible. @@ -93,6 +115,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 => { + if (sibling !== element && !visibleNodes.has(sibling) && !hiddenNodes.has(sibling) && sibling instanceof Element) { + hide(sibling); + } + }); + } + }; + + // Main function to process each target element + targets.forEach(target => { + let current = target; + // Process up to and including the body element + while (current && current !== document.body) { + hideSiblings(current); + // Move to the host element if current is within a shadow DOM + if (current.getRootNode() instanceof ShadowRoot) { + current = (current.getRootNode() as ShadowRoot).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 +181,12 @@ export function ariaHideOutside(targets: Element[], root = document.body) { } }); + if (shadowRoots) { + shadowRoots.forEach(shadowRoot => { + observer.observe(shadowRoot, {childList: true, subtree: true}); + }); + } + observer.observe(root, {childList: true, subtree: true}); let observerWrapper = { @@ -147,6 +203,12 @@ export function ariaHideOutside(targets: Element[], root = document.body) { return () => { observer.disconnect(); + if (shadowRoots) { + shadowRoots.forEach(() => { + observer.disconnect(); + }); + } + for (let node of hiddenNodes) { let count = refCountMap.get(node); if (count === 1) { From ab29c291ebc670444962050d188efa578c657d79 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 8 Apr 2024 06:47:00 +0200 Subject: [PATCH 08/11] Update the fix. --- .../overlays/src/ariaHideOutside.ts | 60 ++-- .../overlays/test/ariaHideOutside.test.js | 267 ++++++++++++++++++ 2 files changed, 299 insertions(+), 28 deletions(-) diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 2a4f668188b..28c2529b974 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -35,19 +35,21 @@ export function ariaHideOutside(targets: Element[], root = document.body) { let refCountMap = new WeakMap(); const shadowRoots = findShadowRoots(targets); - // Add all ancestors of each target to the set of visible nodes to ensure they are not hidden - targets.forEach(target => { - let current = target; - while (current && current !== document.body) { - visibleNodes.add(current); - if (current.getRootNode() instanceof ShadowRoot) { - // If within a shadow DOM, add the host element - current = (current.getRootNode() as ShadowRoot).host; - } else { - current = current.parentNode as Element; + if (shadowRoots.length > 0) { + // Add all ancestors of each target to the set of visible nodes to ensure they are not hidden + targets.forEach(target => { + let current = target; + while (current && current !== document.body) { + visibleNodes.add(current); + if (current.getRootNode() instanceof ShadowRoot) { + // If within a shadow DOM, add the host element + current = (current.getRootNode() as ShadowRoot).host; + } else { + current = current.parentNode as Element; + } } - } - }); + }); + } let walk = (root: Element) => { // Keep live announcer and top layer elements (e.g. toasts) visible. @@ -127,21 +129,23 @@ export function ariaHideOutside(targets: Element[], root = document.body) { } }; - // Main function to process each target element - targets.forEach(target => { - let current = target; - // Process up to and including the body element - while (current && current !== document.body) { - hideSiblings(current); - // Move to the host element if current is within a shadow DOM - if (current.getRootNode() instanceof ShadowRoot) { - current = (current.getRootNode() as ShadowRoot).host; - } else { - // Otherwise, just move to the parent node - current = current.parentNode as Element; + if (shadowRoots.length > 0) { + // Main function to process each target element + targets.forEach(target => { + let current = target; + // Process up to and including the body element + while (current && current !== document.body) { + hideSiblings(current); + // Move to the host element if current is within a shadow DOM + if (current.getRootNode() instanceof ShadowRoot) { + current = (current.getRootNode() as ShadowRoot).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. @@ -181,7 +185,7 @@ export function ariaHideOutside(targets: Element[], root = document.body) { } }); - if (shadowRoots) { + if (shadowRoots.length > 0) { shadowRoots.forEach(shadowRoot => { observer.observe(shadowRoot, {childList: true, subtree: true}); }); @@ -203,7 +207,7 @@ export function ariaHideOutside(targets: Element[], root = document.body) { return () => { observer.disconnect(); - if (shadowRoots) { + if (shadowRoots.length > 0) { shadowRoots.forEach(() => { observer.disconnect(); }); diff --git a/packages/@react-aria/overlays/test/ariaHideOutside.test.js b/packages/@react-aria/overlays/test/ariaHideOutside.test.js index 44434ba32fd..4e1b199d7b4 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'; describe('ariaHideOutside', function () { @@ -384,4 +385,270 @@ 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'); + }); + }); }); From 4fb588960f632b85b45717b22d13abd568104ce7 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 8 Apr 2024 15:49:10 +0200 Subject: [PATCH 09/11] Update the fix. --- .../@react-aria/overlays/src/ariaHideOutside.ts | 14 ++++++++------ .../overlays/test/ariaHideOutside.test.js | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 28c2529b974..7ecc2b86c5c 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -103,6 +103,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) { @@ -121,8 +125,8 @@ export function ariaHideOutside(targets: Element[], root = document.body) { const hideSiblings = (element: Element) => { let parentNode = element.parentNode; if (parentNode) { - parentNode.childNodes.forEach(sibling => { - if (sibling !== element && !visibleNodes.has(sibling) && !hiddenNodes.has(sibling) && sibling instanceof Element) { + parentNode.childNodes.forEach((sibling: Element) => { + if (sibling !== element && !visibleNodes.has(sibling) && !hiddenNodes.has(sibling)) { hide(sibling); } }); @@ -130,15 +134,13 @@ export function ariaHideOutside(targets: Element[], root = document.body) { }; if (shadowRoots.length > 0) { - // Main function to process each target element targets.forEach(target => { let current = target; // Process up to and including the body element while (current && current !== document.body) { hideSiblings(current); - // Move to the host element if current is within a shadow DOM - if (current.getRootNode() instanceof ShadowRoot) { - current = (current.getRootNode() as ShadowRoot).host; + if (current.parentNode instanceof ShadowRoot) { + current = current.parentNode.host; } else { // Otherwise, just move to the parent node current = current.parentNode as Element; diff --git a/packages/@react-aria/overlays/test/ariaHideOutside.test.js b/packages/@react-aria/overlays/test/ariaHideOutside.test.js index 4e1b199d7b4..0a8c9ad562c 100644 --- a/packages/@react-aria/overlays/test/ariaHideOutside.test.js +++ b/packages/@react-aria/overlays/test/ariaHideOutside.test.js @@ -603,8 +603,8 @@ describe('ariaHideOutside', function () {
-
-
+
+
`; From de8c4516db99fbcd876662cd6fbdb778023a9282 Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Fri, 1 Nov 2024 07:35:17 +0200 Subject: [PATCH 10/11] - Fixes ariaHideOutside in Shadow DOM for multiple targets that share the same shadow root as a parent. --- .../overlays/src/ariaHideOutside.ts | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index 7ecc2b86c5c..c7fb12d597f 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -36,21 +36,37 @@ export function ariaHideOutside(targets: Element[], root = document.body) { const shadowRoots = findShadowRoots(targets); if (shadowRoots.length > 0) { - // Add all ancestors of each target to the set of visible nodes to ensure they are not hidden + // First, identify which shadow root we're dealing with + const targetShadowRoot = targets[0].getRootNode() as ShadowRoot; + targets.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 !== document.body) { - visibleNodes.add(current); - if (current.getRootNode() instanceof ShadowRoot) { - // If within a shadow DOM, add the host element - current = (current.getRootNode() as ShadowRoot).host; - } else { - current = current.parentNode as Element; - } + while (current && current.parentElement && current.parentElement !== targetShadowRoot.host) { + visibleNodes.add(current.parentElement); + current = current.parentElement; } + + // Add the shadow host and its ancestors up to document.body + let host = targetShadowRoot.host; + while (host && host !== document.body) { + visibleNodes.add(host); + host = host.parentElement; + } + visibleNodes.add(document.body); }); } + console.log(visibleNodes); + 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]')) { From 83661d3c629e9dc7e68f9386ddc7bafa83c58fea Mon Sep 17 00:00:00 2001 From: Mahmoud Elsayad Date: Mon, 4 Nov 2024 04:23:24 +0200 Subject: [PATCH 11/11] - Update `ariaHideOutside` to pass unit tests. --- .../overlays/src/ariaHideOutside.ts | 63 +++++++++++++------ .../overlays/test/ariaHideOutside.test.js | 49 +++++++++++++++ 2 files changed, 92 insertions(+), 20 deletions(-) diff --git a/packages/@react-aria/overlays/src/ariaHideOutside.ts b/packages/@react-aria/overlays/src/ariaHideOutside.ts index c7fb12d597f..296a32b748a 100644 --- a/packages/@react-aria/overlays/src/ariaHideOutside.ts +++ b/packages/@react-aria/overlays/src/ariaHideOutside.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +let refCountMap = new WeakMap(); let observerStack = []; function isInShadowDOM(node) { @@ -32,40 +33,62 @@ function findShadowRoots(targets) { export function ariaHideOutside(targets: Element[], root = document.body) { let visibleNodes = new Set(targets); let hiddenNodes = new Set(); - let refCountMap = new WeakMap(); const shadowRoots = findShadowRoots(targets); if (shadowRoots.length > 0) { - // First, identify which shadow root we're dealing with - const targetShadowRoot = targets[0].getRootNode() as ShadowRoot; + const targetsByShadowRoot = new Map(); targets.forEach(target => { - // Add the target itself - visibleNodes.add(target); - - // Add its parent container within shadow root - if (target.parentElement) { - visibleNodes.add(target.parentElement); + 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; + } } + }); - // Walk up until we hit the shadow root's immediate child - let current = target; - while (current && current.parentElement && current.parentElement !== targetShadowRoot.host) { - visibleNodes.add(current.parentElement); - 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 = targetShadowRoot.host; + let host = shadowRoot.host; while (host && host !== document.body) { visibleNodes.add(host); - host = host.parentElement; + if (host.getRootNode() instanceof ShadowRoot) { + host = (host.getRootNode() as ShadowRoot).host; + } else { + host = host.parentElement; + } } - visibleNodes.add(document.body); }); - } - console.log(visibleNodes); + // Always add document.body + visibleNodes.add(document.body); + } let walk = (root: Element) => { // Keep live announcer and top layer elements (e.g. toasts) visible. diff --git a/packages/@react-aria/overlays/test/ariaHideOutside.test.js b/packages/@react-aria/overlays/test/ariaHideOutside.test.js index 7fd5e16f2f7..3696ae79c59 100644 --- a/packages/@react-aria/overlays/test/ariaHideOutside.test.js +++ b/packages/@react-aria/overlays/test/ariaHideOutside.test.js @@ -650,5 +650,54 @@ describe('ariaHideOutside', function () { 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(); + }); }); });