Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shadow DOM support for ariaHideOutside #6160

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
74 changes: 71 additions & 3 deletions packages/@react-aria/overlays/src/ariaHideOutside.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element, number>();
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
Expand All @@ -26,6 +32,24 @@ let observerStack = [];
export function ariaHideOutside(targets: Element[], root = document.body) {
let visibleNodes = new Set<Element>(targets);
let hiddenNodes = new Set<Element>();
let refCountMap = new WeakMap<Element, number>();
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
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.
Expand Down Expand Up @@ -79,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) {
Expand All @@ -93,6 +121,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) {
Expand Down Expand Up @@ -131,6 +187,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 = {
Expand All @@ -147,6 +209,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) {
Expand Down
267 changes: 267 additions & 0 deletions packages/@react-aria/overlays/test/ariaHideOutside.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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 = () => (
<>
<div id="modal" role="dialog">Modal Content</div>
</>
);
ReactDOM.render(<ExampleModal />, 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 = '<div id="modal" role="dialog">Modal Content</div>';

shadowRoot.innerHTML += '<div id="insideContent">Inside Shadow Content</div>';

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 = '<div id="modal" role="dialog">Modal Content</div>';
}
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}) => (
<>
<div id="modal" role="dialog">Modal Content</div>
{showExtraContent && <div id="extraContent">Extra Content</div>}
</>
);

ReactDOM.render(<ExampleDynamicContent showExtraContent={false} />, shadowRoot);

// Apply ariaHideOutside
ariaHideOutside([shadowRoot.getElementById('modal')]);

// Dynamically update the content inside the Shadow DOM
ReactDOM.render(<ExampleDynamicContent showExtraContent />, 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 = `
<div id="P1">
<div id="C1"></div>
<div id="C2"></div>
</div>
<div id="P2">
<div id="C3"></div>
<div id="C4"></div>
</div>
`;

// 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 = `
<div id="innerC1">Inner Content C3-1</div>
<div id="innerC2">Inner Content C3-2</div>
`;

// 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 = `
<div id="C5"></div>
<div id="C6"></div>
`;

// 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 = `
<div id="modal">Modal Content</div>
`;

// 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');
});
});
});