Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/mui-material/src/Modal/Modal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,21 @@ describe('<Modal />', () => {

expect(within(screen.getByTestId('parent')).getByTestId('child')).not.to.equal(null);
});

it('should not apply aria-hidden to an ancestor of the modal', () => {
render(
<div data-testid="app">
<div data-testid="app-content" />
<Modal open disablePortal>
<div data-testid="modal-content" />
</Modal>
</div>,
);

expect(screen.getByTestId('app')).not.toBeInaccessible();
expect(screen.getByTestId('modal-content')).not.toBeInaccessible();
expect(screen.getByTestId('app-content')).toBeInaccessible();
});
});

describe('prop: slotProps.backdrop', () => {
Expand Down
133 changes: 128 additions & 5 deletions packages/mui-material/src/Modal/ModalManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import getScrollbarSize from '@mui/utils/getScrollbarSize';
import { ModalManager } from './ModalManager';

interface Modal {
mount: Element;
modalRef: Element;
mount: HTMLElement;
modalRef: HTMLElement;
}

function getDummyModal(): Modal {
Expand Down Expand Up @@ -324,7 +324,7 @@ describe('ModalManager', () => {
});

describe('container aria-hidden', () => {
let modalRef1;
let modalRef1: HTMLDivElement;
let container2: HTMLDivElement;

beforeEach(() => {
Expand Down Expand Up @@ -411,7 +411,7 @@ describe('ModalManager', () => {
});

it('should remove aria-hidden on siblings', () => {
const modal = { ...getDummyModal(), modalRef: container2.children[0] };
const modal = { ...getDummyModal(), modalRef: container2.children[0] as HTMLElement };

modalManager.add(modal, container2);
modalManager.mount(modal, {});
Expand All @@ -421,7 +421,7 @@ describe('ModalManager', () => {
});

it('should keep previous aria-hidden siblings hidden', () => {
const modal = { ...getDummyModal(), modalRef: container2.children[0] };
const modal = { ...getDummyModal(), modalRef: container2.children[0] as HTMLElement };
const sibling1 = document.createElement('div');
const sibling2 = document.createElement('div');

Expand All @@ -438,5 +438,128 @@ describe('ModalManager', () => {
expect(container2.children[1]).toBeInaccessible();
expect(container2.children[2]).not.toBeInaccessible();
});

it('should hide the siblings of a non-portaled modal at every level', () => {
// container2 > nestedParent > [appContent, modalRef]
const nestedParent = document.createElement('div');
const appContent = document.createElement('div');
const modalRef = document.createElement('div');
nestedParent.appendChild(appContent);
nestedParent.appendChild(modalRef);
container2.appendChild(nestedParent);

modalManager.add({ ...getDummyModal(), modalRef }, container2);

// The modal and its ancestor stay readable.
expect(nestedParent).not.toBeInaccessible();
expect(modalRef).not.toBeInaccessible();
// Everything beside the modal is hidden - inside the ancestor and above it.
expect(appContent).toBeInaccessible();
expect(modalRef1).toBeInaccessible();
});

it('should restore aria-hidden at every level when the modal is removed', () => {
const nestedParent = document.createElement('div');
const appContent = document.createElement('div');
const modalRef = document.createElement('div');
nestedParent.appendChild(appContent);
nestedParent.appendChild(modalRef);
container2.appendChild(nestedParent);

const modal = { ...getDummyModal(), modalRef };
modalManager.add(modal, container2);
modalManager.mount(modal, {});
expect(appContent).toBeInaccessible();

modalManager.remove(modal);
expect(appContent).not.toBeInaccessible();
expect(modalRef1).not.toBeInaccessible();
expect(nestedParent).not.toBeInaccessible();
});

it('should not restore aria-hidden that the app set itself, at any depth', () => {
const nestedParent = document.createElement('div');
const appHidden = document.createElement('div');
appHidden.setAttribute('aria-hidden', 'true');
const modalRef = document.createElement('div');
nestedParent.appendChild(appHidden);
nestedParent.appendChild(modalRef);
container2.appendChild(nestedParent);

const modal = { ...getDummyModal(), modalRef };
modalManager.add(modal, container2);
modalManager.mount(modal, {});
modalManager.remove(modal);

// The manager never owned this attribute, so it must survive.
expect(appHidden).toBeInaccessible();
});

// Two modals
it('should unhide the modal below when the top modal is removed', () => {
const lowerRef = document.createElement('div');
const upperRef = document.createElement('div');
container2.appendChild(lowerRef);
container2.appendChild(upperRef);

const lower = { ...getDummyModal(), modalRef: lowerRef };
const upper = { ...getDummyModal(), modalRef: upperRef };

modalManager.add(lower, container2);
expect(lowerRef).not.toBeInaccessible();

modalManager.add(upper, container2);
expect(lowerRef).toBeInaccessible();
expect(upperRef).not.toBeInaccessible();

modalManager.remove(upper);
expect(lowerRef).not.toBeInaccessible();
expect(modalRef1).toBeInaccessible();
});

// Three modals
it('should keep only the new top modal readable when one of three is removed', () => {
const first = document.createElement('div');
const second = document.createElement('div');
const third = document.createElement('div');
container2.appendChild(first);
container2.appendChild(second);
container2.appendChild(third);

modalManager.add({ ...getDummyModal(), modalRef: first }, container2);
modalManager.add({ ...getDummyModal(), modalRef: second }, container2);
const top = { ...getDummyModal(), modalRef: third };
modalManager.add(top, container2);
expect(third).not.toBeInaccessible();

modalManager.remove(top);
expect(second).not.toBeInaccessible();
expect(first).toBeInaccessible();
expect(third).toBeInaccessible();
});

it('should release deep aria-hidden when a shallower modal takes the top', () => {
const wrapper = document.createElement('div');
const appContent = document.createElement('div');
const inlineRef = document.createElement('div');
wrapper.appendChild(appContent);
wrapper.appendChild(inlineRef);
container2.appendChild(wrapper);

const portalRef = document.createElement('div');
container2.appendChild(portalRef);

modalManager.add({ ...getDummyModal(), modalRef: inlineRef }, container2);
expect(appContent).toBeInaccessible();
expect(wrapper).not.toBeInaccessible();

modalManager.add({ ...getDummyModal(), modalRef: portalRef }, container2);
// `wrapper` is hidden as a whole now, so the deep attribute inside it is
// redundant and must be released. Assert the attribute directly. The
// accessibility check would report it inaccessible via its hidden ancestor.
expect(wrapper).toBeInaccessible();
expect(appContent.getAttribute('aria-hidden')).to.equal(null);
expect(portalRef).not.toBeInaccessible();
});
});
});
123 changes: 85 additions & 38 deletions packages/mui-material/src/Modal/ModalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,22 +54,74 @@ function isAriaHiddenForbiddenOnElement(element: Element): boolean {
return isForbiddenTagName || isInputHidden;
}

function ariaHiddenSiblings(
container: Element,
mountElement: Element,
currentElement: Element,
elementsToExclude: readonly Element[],
hide: boolean,
// The chain of elements from `node` up to (but not including) `container`
// that should not be aria-hidden.
function getKeepChain(
node: HTMLElement | SVGElement,
container: HTMLElement | SVGElement,
): Set<HTMLElement | SVGElement> {
const chain = new Set<HTMLElement | SVGElement>();
let current: HTMLElement | SVGElement | null = node;

while (current && current !== container) {
chain.add(current);
current = current.parentElement;
}

return current === container ? chain : new Set<HTMLElement | SVGElement>();
}

// Walk down from `parent` collecting everything that should be aria-hidden.
// An element on the keep chain is stepped through rather than hidden, so the
// modal's own ancestors stay readable while their other children get hidden.
function collectHiddenTargets(
parent: HTMLElement | SVGElement,
keep: HTMLElement | SVGElement,
keepChain: Set<HTMLElement | SVGElement>,
out: Set<HTMLElement | SVGElement>,
): void {
const blacklist = [mountElement, currentElement, ...elementsToExclude];
[].forEach.call(parent.children, (element: HTMLElement | SVGElement) => {
if (element === keep || isAriaHiddenForbiddenOnElement(element)) {
return;
}

if (keepChain.has(element)) {
collectHiddenTargets(element, keep, keepChain, out);
return;
}

out.add(element);
});
}

function syncAriaHidden(containerInfo: Container): void {
const { container, modals, hiddenSiblings } = containerInfo;
const top = modals[modals.length - 1];
const keep = top.modalRef;

const next = new Set<HTMLElement | SVGElement>();
collectHiddenTargets(container, keep, getKeepChain(keep, container), next);

[].forEach.call(container.children, (element: Element) => {
const isNotExcludedElement = !blacklist.includes(element);
const isNotForbiddenElement = !isAriaHiddenForbiddenOnElement(element);
if (isNotExcludedElement && isNotForbiddenElement) {
ariaHidden(element, hide);
next.forEach((element) => {
if (!containerInfo.hiddenSet.has(element) && element.getAttribute('aria-hidden') === 'true') {
next.delete(element);
}
});
hiddenSiblings.forEach((element) => next.delete(element));

// Hands the accessibility tree back to a parent dialog when a nested one closes.
containerInfo.hiddenSet.forEach((element) => {
if (!next.has(element)) {
ariaHidden(element, false);
}
});
next.forEach((element) => ariaHidden(element, true));

if (keep) {
ariaHidden(keep, false);
}

containerInfo.hiddenSet = next;
}

function handleContainer(containerInfo: Container, props: ManagedModalProps) {
Expand Down Expand Up @@ -160,9 +212,9 @@ function handleContainer(containerInfo: Container, props: ManagedModalProps) {
return restore;
}

function getHiddenSiblings(container: Element) {
const hiddenSiblings: Element[] = [];
[].forEach.call(container.children, (element: Element) => {
function getHiddenSiblings(container: HTMLElement | SVGElement) {
const hiddenSiblings: Container['hiddenSiblings'] = [];
[].forEach.call(container.children, (element: HTMLElement | SVGElement) => {
if (element.getAttribute('aria-hidden') === 'true') {
hiddenSiblings.push(element);
}
Expand All @@ -171,13 +223,14 @@ function getHiddenSiblings(container: Element) {
}

interface Modal {
mount: Element;
modalRef: Element;
mount: HTMLElement | SVGElement;
modalRef: HTMLElement | SVGElement;
}

interface Container {
container: HTMLElement;
hiddenSiblings: Element[];
hiddenSiblings: (HTMLElement | SVGElement)[];
hiddenSet: Set<HTMLElement | SVGElement>;
modals: Modal[];
restore: null | (() => void);
}
Expand Down Expand Up @@ -213,21 +266,23 @@ export class ModalManager {
ariaHidden(modal.modalRef, false);
}

const hiddenSiblings = getHiddenSiblings(container);
ariaHiddenSiblings(container, modal.mount, modal.modalRef, hiddenSiblings, true);

const containerIndex = this.containers.findIndex((item) => item.container === container);

if (containerIndex !== -1) {
this.containers[containerIndex].modals.push(modal);
syncAriaHidden(this.containers[containerIndex]);
return modalIndex;
}

this.containers.push({
const containerInfo: Container = {
modals: [modal],
container,
restore: null,
hiddenSiblings,
});
hiddenSiblings: getHiddenSiblings(container),
hiddenSet: new Set(),
};
this.containers.push(containerInfo);
syncAriaHidden(containerInfo);

return modalIndex;
}
Expand Down Expand Up @@ -266,22 +321,14 @@ export class ModalManager {
ariaHidden(modal.modalRef, ariaHiddenState);
}

ariaHiddenSiblings(
containerInfo.container,
modal.mount,
modal.modalRef,
containerInfo.hiddenSiblings,
false,
);
containerInfo.hiddenSet.forEach((element) => ariaHidden(element, false));
containerInfo.hiddenSet.clear();
this.containers.splice(containerIndex, 1);
} else {
// Otherwise make sure the next top modal is visible to a screen reader.
const nextTop = containerInfo.modals[containerInfo.modals.length - 1];
// as soon as a modal is adding its modalRef is undefined. it can't set
// aria-hidden because the dom element doesn't exist either
// when modal was unmounted before modalRef gets null
if (nextTop.modalRef) {
ariaHidden(nextTop.modalRef, false);
syncAriaHidden(containerInfo);

if (modal.modalRef) {
ariaHidden(modal.modalRef, ariaHiddenState);
}
}

Expand Down
Loading