Skip to content

Commit c2bac91

Browse files
committed
fix(Modal): updated logic to set aria-hidden for tearsheets
1 parent 4d61988 commit c2bac91

3 files changed

Lines changed: 133 additions & 7 deletions

File tree

packages/react-core/src/components/Modal/Modal.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ interface ModalState {
6969
class Modal extends Component<ModalProps, ModalState> {
7070
static displayName = 'Modal';
7171
static currentId = 0;
72+
static openModalStack: string[] = [];
7273
boxId = '';
7374
backdropId = '';
7475

@@ -109,12 +110,24 @@ class Modal extends Component<ModalProps, ModalState> {
109110
toggleSiblingsFromScreenReaders = (hide: boolean) => {
110111
const { appendTo } = this.props;
111112
const target: HTMLElement = this.getElement(appendTo);
112-
const bodyChildren = target.children;
113-
for (const child of Array.from(bodyChildren)) {
114-
const isPopperElement = child.hasAttribute('data-popper-placement');
115-
if (child.id !== this.backdropId && !isPopperElement) {
116-
hide ? child.setAttribute('aria-hidden', '' + hide) : child.removeAttribute('aria-hidden');
113+
const idx = Modal.openModalStack.indexOf(this.backdropId);
114+
115+
if (hide && idx === -1) {
116+
Modal.openModalStack.push(this.backdropId);
117+
} else if (!hide && idx !== -1) {
118+
Modal.openModalStack.splice(idx, 1);
119+
}
120+
121+
const activeBackdropId =
122+
Modal.openModalStack.length > 0 ? Modal.openModalStack[Modal.openModalStack.length - 1] : null;
123+
124+
for (const child of Array.from(target.children)) {
125+
// We need to prevent aria-hidden being applied to popper elements appended to document.body
126+
if (child.hasAttribute('data-popper-placement')) {
127+
continue;
117128
}
129+
const shouldHide = activeBackdropId && child.id !== activeBackdropId;
130+
shouldHide ? child.setAttribute('aria-hidden', 'true') : child.removeAttribute('aria-hidden');
118131
}
119132
};
120133

@@ -140,8 +153,10 @@ class Modal extends Component<ModalProps, ModalState> {
140153
this.toggleSiblingsFromScreenReaders(true);
141154
} else {
142155
if (prevProps.isOpen !== this.props.isOpen) {
143-
target.classList.remove(css(styles.backdropOpen));
144156
this.toggleSiblingsFromScreenReaders(false);
157+
if (Modal.openModalStack.length === 0) {
158+
target.classList.remove(css(styles.backdropOpen));
159+
}
145160
}
146161
}
147162
}
@@ -150,8 +165,10 @@ class Modal extends Component<ModalProps, ModalState> {
150165
const { appendTo } = this.props;
151166
const target: HTMLElement = this.getElement(appendTo);
152167
target.removeEventListener('keydown', this.handleEscKeyClick, false);
153-
target.classList.remove(css(styles.backdropOpen));
154168
this.toggleSiblingsFromScreenReaders(false);
169+
if (Modal.openModalStack.length === 0) {
170+
target.classList.remove(css(styles.backdropOpen));
171+
}
155172
}
156173

157174
render() {

packages/react-core/src/components/Modal/__tests__/Modal.test.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,28 @@ const ModalWithAdjacentModal = () => {
6464
);
6565
};
6666

67+
const MultipleOpenModals = () => {
68+
const [isFirstOpen, setIsFirstOpen] = useState(true);
69+
const [isSecondOpen, setIsSecondOpen] = useState(false);
70+
71+
return (
72+
<>
73+
<aside>Aside sibling</aside>
74+
<Modal isOpen={isFirstOpen} appendTo={target} onClose={() => setIsFirstOpen(false)} aria-label="First modal">
75+
<button onClick={() => setIsSecondOpen(true)}>Open second modal</button>
76+
</Modal>
77+
<Modal isOpen={isSecondOpen} appendTo={target} onClose={() => setIsSecondOpen(false)} aria-label="Second modal">
78+
Second modal content
79+
</Modal>
80+
</>
81+
);
82+
};
83+
6784
describe('Modal', () => {
85+
beforeEach(() => {
86+
Modal.openModalStack = [];
87+
});
88+
6889
test('Modal creates a container element once for div', () => {
6990
render(<Modal {...props} />);
7091
expect(document.createElement).toHaveBeenCalledWith('div');
@@ -181,4 +202,67 @@ describe('Modal', () => {
181202
'pf-v6-l-bullseye'
182203
);
183204
});
205+
206+
test('backdropOpen class remains when closing one of multiple open modals', async () => {
207+
const user = userEvent.setup();
208+
209+
render(<MultipleOpenModals />, { container: document.body.appendChild(target) });
210+
211+
await user.click(screen.getByRole('button', { name: 'Open second modal' }));
212+
213+
expect(target).toHaveClass(css(styles.backdropOpen));
214+
215+
const closeButtons = screen.getAllByRole('button', { name: 'Close', hidden: true });
216+
await user.click(closeButtons[closeButtons.length - 1]);
217+
218+
expect(target).toHaveClass(css(styles.backdropOpen));
219+
});
220+
221+
test('backdropOpen class is removed when all modals are closed', async () => {
222+
const user = userEvent.setup();
223+
224+
render(<MultipleOpenModals />, { container: document.body.appendChild(target) });
225+
226+
await user.click(screen.getByRole('button', { name: 'Open second modal' }));
227+
228+
const closeButtons = screen.getAllByRole('button', { name: 'Close', hidden: true });
229+
await user.click(closeButtons[closeButtons.length - 1]);
230+
await user.click(screen.getByRole('button', { name: 'Close' }));
231+
232+
expect(target).not.toHaveClass(css(styles.backdropOpen));
233+
});
234+
235+
test('only the most recent modal does not have aria-hidden when multiple modals are open', async () => {
236+
const user = userEvent.setup();
237+
238+
render(<MultipleOpenModals />, { container: document.body.appendChild(target) });
239+
240+
const firstBackdrop = screen.getByLabelText('First modal').closest('[class*="backdrop"]');
241+
242+
await user.click(screen.getByRole('button', { name: 'Open second modal' }));
243+
244+
const secondBackdrop = screen.getByLabelText('Second modal').closest('[class*="backdrop"]');
245+
246+
expect(firstBackdrop).toHaveAttribute('aria-hidden', 'true');
247+
expect(secondBackdrop).not.toHaveAttribute('aria-hidden');
248+
});
249+
250+
test('closing the active modal reveals the previous modal', async () => {
251+
const user = userEvent.setup();
252+
253+
render(<MultipleOpenModals />, { container: document.body.appendChild(target) });
254+
255+
await user.click(screen.getByRole('button', { name: 'Open second modal' }));
256+
257+
const firstBackdrop = screen
258+
.getByLabelText('First modal', { selector: '[role="dialog"]' })
259+
.closest('[class*="backdrop"]');
260+
261+
expect(firstBackdrop).toHaveAttribute('aria-hidden', 'true');
262+
263+
const closeButtons = screen.getAllByRole('button', { name: 'Close', hidden: true });
264+
await user.click(closeButtons[closeButtons.length - 1]);
265+
266+
expect(firstBackdrop).not.toHaveAttribute('aria-hidden');
267+
});
184268
});

packages/react-core/src/components/Modal/examples/ModalBasic.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from '@patternfly/
33

44
export const ModalBasic: React.FunctionComponent = () => {
55
const [isModalOpen, setIsModalOpen] = useState(false);
6+
const [isModal2Open, setIsModal2Open] = useState(false);
67

78
const handleModalToggle = (_event: KeyboardEvent | React.MouseEvent) => {
89
setIsModalOpen(!isModalOpen);
910
};
11+
const handleModal2Toggle = (_event: KeyboardEvent | React.MouseEvent) => {
12+
setIsModal2Open(!isModal2Open);
13+
};
1014

1115
return (
1216
<Fragment>
@@ -27,6 +31,9 @@ export const ModalBasic: React.FunctionComponent = () => {
2731
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
2832
pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
2933
est laborum.
34+
<Button variant="primary" onClick={handleModal2Toggle} ouiaId="ShowBasicModal">
35+
Show basic modal
36+
</Button>
3037
</ModalBody>
3138
<ModalFooter>
3239
<Button key="confirm" variant="primary" onClick={handleModalToggle}>
@@ -37,6 +44,24 @@ export const ModalBasic: React.FunctionComponent = () => {
3744
</Button>
3845
</ModalFooter>
3946
</Modal>
47+
<Modal
48+
isOpen={isModal2Open}
49+
onClose={handleModal2Toggle}
50+
ouiaId="BasicModal2"
51+
aria-labelledby="basic2-modal-title"
52+
aria-describedby="modal2-box-body-basic"
53+
>
54+
<ModalHeader title="Nested modal" labelId="basic2-modal-title" />
55+
<ModalBody id="modal2-box-body-basic">Nested modal</ModalBody>
56+
<ModalFooter>
57+
<Button key="confirm2" variant="primary" onClick={handleModal2Toggle}>
58+
Confirm
59+
</Button>
60+
<Button key="cancel2" variant="link" onClick={handleModal2Toggle}>
61+
Cancel
62+
</Button>
63+
</ModalFooter>
64+
</Modal>
4065
</Fragment>
4166
);
4267
};

0 commit comments

Comments
 (0)