diff --git a/packages/mui-material/src/Modal/Modal.test.js b/packages/mui-material/src/Modal/Modal.test.js index f04146da17e7aa..77d7a4faa427fe 100644 --- a/packages/mui-material/src/Modal/Modal.test.js +++ b/packages/mui-material/src/Modal/Modal.test.js @@ -799,6 +799,29 @@ describe('', () => { setProps({ anchorEl: document.body }); }); + it('should remove aria-hidden from the previous container when the container changes', () => { + const root = document.createElement('div'); + root.setAttribute('id', 'root'); + document.body.appendChild(root); + + try { + const { setProps } = render( + +
+ , + ); + + // The modal is mounted in the body, making #root an aria-hidden sibling. + expect(root).toBeInaccessible(); + + // Moving the modal into #root must remove the stale aria-hidden from it. + setProps({ container: root }); + expect(root).not.toBeInaccessible(); + } finally { + document.body.removeChild(root); + } + }); + it('should finish closing when the container changes during the exit transition', () => { function TestCase(props) { const firstContainerRef = React.useRef(null); diff --git a/packages/mui-material/src/Modal/ModalManager.test.ts b/packages/mui-material/src/Modal/ModalManager.test.ts index 2d57307cdb651e..a649ad6ff13e57 100644 --- a/packages/mui-material/src/Modal/ModalManager.test.ts +++ b/packages/mui-material/src/Modal/ModalManager.test.ts @@ -350,6 +350,20 @@ describe('ModalManager', () => { expect(modal2).not.toBeInaccessible(); }); + it('should not add aria-hidden to container siblings that contain the modal', () => { + // Simulates a non-portaled modal (disablePortal) whose DOM node is nested + // inside one of the container's children. Hiding that child would make the + // modal itself inaccessible. + const nestedParent = document.createElement('div'); + const nestedModalRef = document.createElement('div'); + nestedParent.appendChild(nestedModalRef); + container2.appendChild(nestedParent); + + modalManager.add({ ...getDummyModal(), modalRef: nestedModalRef }, container2); + + expect(nestedParent).not.toBeInaccessible(); + }); + it('should add aria-hidden to container siblings', () => { const secondSibling = document.createElement('input'); container2.appendChild(secondSibling); @@ -438,5 +452,36 @@ describe('ModalManager', () => { expect(container2.children[1]).toBeInaccessible(); expect(container2.children[2]).not.toBeInaccessible(); }); + + it('should restore aria-hidden when a modal is re-registered to a nested container', () => { + const containerA = document.createElement('div'); + const containerB = document.createElement('div'); + const sibling = document.createElement('div'); + const modalRef = document.createElement('div'); + containerA.appendChild(sibling); + containerA.appendChild(containerB); + containerA.appendChild(modalRef); + document.body.appendChild(containerA); + + try { + const modal = { mount: containerA, modalRef }; + modalManager.add(modal, containerA); + modalManager.mount(modal, {}); + expect(containerB).toBeInaccessible(); + expect(sibling).toBeInaccessible(); + + // Simulates the portal having moved into containerB before the manager is notified. + modal.mount = containerB; + + modalManager.remove(modal); + modalManager.add(modal, containerB); + + expect(containerB).not.toBeInaccessible(); + expect(sibling).not.toBeInaccessible(); + expect(modalRef).not.toBeInaccessible(); + } finally { + document.body.removeChild(containerA); + } + }); }); }); diff --git a/packages/mui-material/src/Modal/ModalManager.ts b/packages/mui-material/src/Modal/ModalManager.ts index 54a976e7bd4cf8..4fcfa49a0f9d3b 100644 --- a/packages/mui-material/src/Modal/ModalManager.ts +++ b/packages/mui-material/src/Modal/ModalManager.ts @@ -66,7 +66,11 @@ function ariaHiddenSiblings( [].forEach.call(container.children, (element: Element) => { const isNotExcludedElement = !blacklist.includes(element); const isNotForbiddenElement = !isAriaHiddenForbiddenOnElement(element); - if (isNotExcludedElement && isNotForbiddenElement) { + // Never hide an element that contains the modal, otherwise the modal itself + // becomes inaccessible. This happens when the modal is not portaled into the + // container, for example when `disablePortal` is used. + const isNotModalAncestor = currentElement ? !element.contains(currentElement) : true; + if (isNotExcludedElement && isNotForbiddenElement && isNotModalAncestor) { ariaHidden(element, hide); } }); @@ -268,7 +272,7 @@ export class ModalManager { ariaHiddenSiblings( containerInfo.container, - modal.mount, + containerInfo.container, modal.modalRef, containerInfo.hiddenSiblings, false, diff --git a/packages/mui-material/src/Modal/useModal.ts b/packages/mui-material/src/Modal/useModal.ts index 2c0a3a94d08b9a..8b6218b31ab1fb 100644 --- a/packages/mui-material/src/Modal/useModal.ts +++ b/packages/mui-material/src/Modal/useModal.ts @@ -46,6 +46,7 @@ function useModal(parameters: UseModalParameters): UseModalReturnValue { const mountNodeRef = React.useRef(null); const lastMountNodeRef = React.useRef(null); const modalRef = React.useRef(null); + const registeredContainerRef = React.useRef(null); const handleRef = useForkRef(modalRef, rootRef); const [exited, setExited] = React.useState(!open); const hasTransition = getHasTransition(children); @@ -74,7 +75,16 @@ function useModal(parameters: UseModalParameters): UseModalReturnValue { const handleOpen = useEventCallback(() => { const resolvedContainer = getContainer(container) || getDoc().body; + // When the container changes while the modal stays open, the previous container + // keeps the `aria-hidden` state computed for the previous sibling set. + // Unregister the modal first so the new container is evaluated against its own siblings. + if (registeredContainerRef.current && registeredContainerRef.current !== resolvedContainer) { + manager.remove(getModal(), ariaHiddenProp); + registeredContainerRef.current = null; + } + manager.add(getModal(), resolvedContainer as HTMLElement); + registeredContainerRef.current = resolvedContainer as HTMLElement; // The element was already mounted. if (modalRef.current) { @@ -101,6 +111,7 @@ function useModal(parameters: UseModalParameters): UseModalReturnValue { }); const handleClose = React.useCallback(() => { + registeredContainerRef.current = null; manager.remove(getModal(), ariaHiddenProp); }, [ariaHiddenProp]); @@ -116,7 +127,7 @@ function useModal(parameters: UseModalParameters): UseModalReturnValue { } else if (!hasTransition || !closeAfterTransition) { handleClose(); } - }, [open, handleClose, hasTransition, closeAfterTransition, handleOpen]); + }, [open, handleClose, hasTransition, closeAfterTransition, handleOpen, container]); const createHandleKeyDown = (otherHandlers: EventHandlers) => (event: React.KeyboardEvent) => { otherHandlers.onKeyDown?.(event);