diff --git a/packages/react-core/src/components/Modal/Modal.tsx b/packages/react-core/src/components/Modal/Modal.tsx index dac6ebbd864..6dfe09a3ce2 100644 --- a/packages/react-core/src/components/Modal/Modal.tsx +++ b/packages/react-core/src/components/Modal/Modal.tsx @@ -69,6 +69,7 @@ interface ModalState { class Modal extends Component { static displayName = 'Modal'; static currentId = 0; + static openModalStacks: Map = new Map(); boxId = ''; backdropId = ''; @@ -106,16 +107,46 @@ class Modal extends Component { return appendTo || document.body; }; + static getStackForTarget(target: HTMLElement): string[] { + if (!Modal.openModalStacks.has(target)) { + Modal.openModalStacks.set(target, []); + } + return Modal.openModalStacks.get(target)!; + } + toggleSiblingsFromScreenReaders = (hide: boolean) => { const { appendTo } = this.props; const target: HTMLElement = this.getElement(appendTo); - const bodyChildren = target.children; - for (const child of Array.from(bodyChildren)) { - const isPopperElement = child.hasAttribute('data-popper-placement'); - if (child.id !== this.backdropId && !isPopperElement) { - hide ? child.setAttribute('aria-hidden', '' + hide) : child.removeAttribute('aria-hidden'); + + if (hide) { + const stack = Modal.getStackForTarget(target); + if (stack.indexOf(this.backdropId) === -1) { + stack.push(this.backdropId); + } + } else { + const stack = Modal.openModalStacks.get(target); + if (!stack) { + return; + } + const idx = stack.indexOf(this.backdropId); + if (idx !== -1) { + stack.splice(idx, 1); + } + if (stack.length === 0) { + Modal.openModalStacks.delete(target); } } + + const stack = Modal.openModalStacks.get(target); + const activeBackdropId = stack?.length ? stack[stack.length - 1] : null; + + for (const child of Array.from(target.children)) { + if (child.hasAttribute('data-popper-placement')) { + continue; + } + const shouldHide = activeBackdropId && child.id !== activeBackdropId; + shouldHide ? child.setAttribute('aria-hidden', 'true') : child.removeAttribute('aria-hidden'); + } }; isEmpty = (value: string | null | undefined) => value === null || value === undefined || value === ''; @@ -140,8 +171,10 @@ class Modal extends Component { this.toggleSiblingsFromScreenReaders(true); } else { if (prevProps.isOpen !== this.props.isOpen) { - target.classList.remove(css(styles.backdropOpen)); this.toggleSiblingsFromScreenReaders(false); + if (!Modal.openModalStacks.has(target)) { + target.classList.remove(css(styles.backdropOpen)); + } } } } @@ -150,8 +183,10 @@ class Modal extends Component { const { appendTo } = this.props; const target: HTMLElement = this.getElement(appendTo); target.removeEventListener('keydown', this.handleEscKeyClick, false); - target.classList.remove(css(styles.backdropOpen)); this.toggleSiblingsFromScreenReaders(false); + if (!Modal.openModalStacks.has(target)) { + target.classList.remove(css(styles.backdropOpen)); + } } render() { diff --git a/packages/react-core/src/components/Modal/__tests__/Modal.test.tsx b/packages/react-core/src/components/Modal/__tests__/Modal.test.tsx index 7c1c768008c..f4c4ea87feb 100644 --- a/packages/react-core/src/components/Modal/__tests__/Modal.test.tsx +++ b/packages/react-core/src/components/Modal/__tests__/Modal.test.tsx @@ -64,7 +64,28 @@ const ModalWithAdjacentModal = () => { ); }; +const MultipleOpenModals = () => { + const [isFirstOpen, setIsFirstOpen] = useState(true); + const [isSecondOpen, setIsSecondOpen] = useState(false); + + return ( + <> + + setIsFirstOpen(false)} aria-label="First modal"> + + + setIsSecondOpen(false)} aria-label="Second modal"> + Second modal content + + + ); +}; + describe('Modal', () => { + beforeEach(() => { + Modal.openModalStacks = new Map(); + }); + test('Modal creates a container element once for div', () => { render(); expect(document.createElement).toHaveBeenCalledWith('div'); @@ -181,4 +202,135 @@ describe('Modal', () => { 'pf-v6-l-bullseye' ); }); + + test('backdropOpen class remains when closing one of multiple open modals', async () => { + const user = userEvent.setup(); + + render(, { container: document.body.appendChild(target) }); + + await user.click(screen.getByRole('button', { name: 'Open second modal' })); + + expect(target).toHaveClass(css(styles.backdropOpen)); + + const closeButtons = screen.getAllByRole('button', { name: 'Close', hidden: true }); + await user.click(closeButtons[closeButtons.length - 1]); + + expect(target).toHaveClass(css(styles.backdropOpen)); + }); + + test('backdropOpen class is removed when all modals are closed', async () => { + const user = userEvent.setup(); + + render(, { container: document.body.appendChild(target) }); + + await user.click(screen.getByRole('button', { name: 'Open second modal' })); + + const closeButtons = screen.getAllByRole('button', { name: 'Close', hidden: true }); + await user.click(closeButtons[closeButtons.length - 1]); + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(target).not.toHaveClass(css(styles.backdropOpen)); + }); + + test('only the most recent modal does not have aria-hidden when multiple modals are open', async () => { + const user = userEvent.setup(); + + render(, { container: document.body.appendChild(target) }); + + const firstBackdrop = screen.getByLabelText('First modal').closest('[class*="backdrop"]'); + + await user.click(screen.getByRole('button', { name: 'Open second modal' })); + + const secondBackdrop = screen.getByLabelText('Second modal').closest('[class*="backdrop"]'); + + expect(firstBackdrop).toHaveAttribute('aria-hidden', 'true'); + expect(secondBackdrop).not.toHaveAttribute('aria-hidden'); + }); + + test('closing the active modal reveals the previous modal', async () => { + const user = userEvent.setup(); + + render(, { container: document.body.appendChild(target) }); + + await user.click(screen.getByRole('button', { name: 'Open second modal' })); + + const firstBackdrop = screen + .getByLabelText('First modal', { selector: '[role="dialog"]' }) + .closest('[class*="backdrop"]'); + + expect(firstBackdrop).toHaveAttribute('aria-hidden', 'true'); + + const closeButtons = screen.getAllByRole('button', { name: 'Close', hidden: true }); + await user.click(closeButtons[closeButtons.length - 1]); + + expect(firstBackdrop).not.toHaveAttribute('aria-hidden'); + }); + + test('modals with different appendTo targets have independent stacks', async () => { + const user = userEvent.setup(); + const targetA = document.createElement('div'); + const targetB = document.createElement('div'); + document.body.appendChild(targetA); + document.body.appendChild(targetB); + + const siblingA = document.createElement('aside'); + siblingA.textContent = 'Sibling A'; + targetA.appendChild(siblingA); + + const siblingB = document.createElement('aside'); + siblingB.textContent = 'Sibling B'; + targetB.appendChild(siblingB); + + const DistinctTargetModals = () => { + const [isAOpen, setIsAOpen] = useState(true); + const [isBOpen, setIsBOpen] = useState(true); + + return ( + <> + setIsAOpen(false)} aria-label="Modal A"> + Modal A content + + setIsBOpen(false)} aria-label="Modal B"> + Modal B content + + + ); + }; + + render(); + + expect(siblingA).toHaveAttribute('aria-hidden', 'true'); + expect(siblingB).toHaveAttribute('aria-hidden', 'true'); + expect(targetA).toHaveClass(css(styles.backdropOpen)); + expect(targetB).toHaveClass(css(styles.backdropOpen)); + + const closeButtons = screen.getAllByRole('button', { name: 'Close', hidden: true }); + await user.click(closeButtons[1]); + + expect(targetB).not.toHaveClass(css(styles.backdropOpen)); + expect(siblingB).not.toHaveAttribute('aria-hidden'); + + expect(targetA).toHaveClass(css(styles.backdropOpen)); + expect(siblingA).toHaveAttribute('aria-hidden', 'true'); + + document.body.removeChild(targetA); + document.body.removeChild(targetB); + }); + + test('unmounting a never-opened modal with a custom target does not leak a stack entry', () => { + const customTarget = document.createElement('div'); + document.body.appendChild(customTarget); + + const { unmount } = render( + {}}> + Never opened + + ); + + unmount(); + + expect(Modal.openModalStacks.has(customTarget)).toBe(false); + + document.body.removeChild(customTarget); + }); });