Skip to content

Commit 9edbceb

Browse files
authored
fix: close only the topmost modal on Escape (#22168)
1 parent 5162307 commit 9edbceb

5 files changed

Lines changed: 150 additions & 4 deletions

File tree

packages/react/src/components/ComposedModal/ComposedModal-test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,64 @@ describe('state with presence context', () => {
919919
expect(siblingModal).not.toBeInTheDocument();
920920
expect(screen.queryByTestId('modal')).toBeInTheDocument();
921921
});
922+
923+
it('should close only the topmost modal when Escape is pressed', async () => {
924+
const ModalExample = () => {
925+
const [isSiblingOpen, setIsSiblingOpen] = useState(false);
926+
const [isChildOpen, setIsChildOpen] = useState(false);
927+
928+
return (
929+
<ComposedModalPresence open>
930+
<ComposedModal data-testid="modal">
931+
<ModalHeader>Modal Header</ModalHeader>
932+
<ModalBody>
933+
<button
934+
type="button"
935+
data-testid="launch-sibling-modal"
936+
onClick={() => setIsSiblingOpen(true)}>
937+
Launch sibling modal
938+
</button>
939+
</ModalBody>
940+
</ComposedModal>
941+
<ComposedModal
942+
data-testid="sibling-modal"
943+
open={isSiblingOpen}
944+
onClose={() => setIsSiblingOpen(false)}>
945+
<ModalHeader>Modal Header</ModalHeader>
946+
<ModalBody>
947+
<button
948+
type="button"
949+
data-testid="launch-child-modal"
950+
onClick={() => setIsChildOpen(true)}>
951+
Launch child modal
952+
</button>
953+
<ComposedModal
954+
data-testid="child-modal"
955+
open={isChildOpen}
956+
onClose={() => setIsChildOpen(false)}>
957+
<ModalHeader>Modal Header</ModalHeader>
958+
</ComposedModal>
959+
</ModalBody>
960+
</ComposedModal>
961+
</ComposedModalPresence>
962+
);
963+
};
964+
965+
render(<ModalExample />);
966+
967+
await userEvent.click(screen.getByTestId('launch-sibling-modal'));
968+
await userEvent.click(screen.getByTestId('launch-child-modal'));
969+
970+
expect(screen.queryByTestId('modal')).toBeInTheDocument();
971+
expect(screen.queryByTestId('sibling-modal')).toBeInTheDocument();
972+
expect(screen.queryByTestId('child-modal')).toBeInTheDocument();
973+
974+
await userEvent.keyboard('{Escape}');
975+
976+
expect(screen.queryByTestId('modal')).toBeInTheDocument();
977+
expect(screen.queryByTestId('sibling-modal')).toBeInTheDocument();
978+
expect(screen.queryByTestId('child-modal')).not.toBeInTheDocument();
979+
});
922980
});
923981

924982
const ComposedModalWithPresenceHof = withComposedModalPresence((props) => {

packages/react/src/components/ComposedModal/ComposedModal.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
} from './ComposedModalPresence';
5050
import { useId } from '../../internal/useId';
5151
import { useComposedModalState } from './useComposedModalState';
52+
import { isTopmostVisibleModal } from '../Modal/isTopmostVisibleModal';
5253

5354
export interface ModalBodyProps extends HTMLAttributes<HTMLDivElement> {
5455
/** Specify the content to be placed in the ModalBody. */
@@ -293,10 +294,15 @@ const ComposedModalDialog = React.forwardRef<
293294
const button = useRef<HTMLButtonElement>(null);
294295
const startSentinel = useRef<HTMLButtonElement>(null);
295296
const endSentinel = useRef<HTMLButtonElement>(null);
297+
const modalRef = useRef<HTMLDivElement>(null);
296298
const onMouseDownTarget = useRef<Node | null>(null);
297299

298300
const presenceContext = useContext(ComposedModalPresenceContext);
299-
const mergedRefs = useMergeRefs([ref, presenceContext?.presenceRef]);
301+
const mergedRefs = useMergeRefs([
302+
modalRef,
303+
ref,
304+
presenceContext?.presenceRef,
305+
]);
300306
const enablePresence =
301307
useFeatureFlag('enable-presence') || presenceContext?.autoEnablePresence;
302308

@@ -486,7 +492,10 @@ const ComposedModalDialog = React.forwardRef<
486492
if (!open) return;
487493

488494
const handleEscapeKey = (event) => {
489-
if (match(event, keys.Escape)) {
495+
if (
496+
match(event, keys.Escape) &&
497+
isTopmostVisibleModal(modalRef.current, prefix)
498+
) {
490499
event.preventDefault();
491500
event.stopPropagation();
492501
closeModal(event);

packages/react/src/components/Modal/Modal-test.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,57 @@ describe('state with presence context', () => {
814814
expect(siblingModal).not.toBeInTheDocument();
815815
expect(screen.queryByTestId('modal')).toBeInTheDocument();
816816
});
817+
818+
it('should close only the topmost modal when Escape is pressed', async () => {
819+
const ModalExample = () => {
820+
const [isSiblingOpen, setIsSiblingOpen] = useState(false);
821+
const [isChildOpen, setIsChildOpen] = useState(false);
822+
823+
return (
824+
<ModalPresence open>
825+
<Modal data-testid="modal">
826+
<button
827+
type="button"
828+
data-testid="launch-sibling-modal"
829+
onClick={() => setIsSiblingOpen(true)}>
830+
Launch sibling modal
831+
</button>
832+
</Modal>
833+
<Modal
834+
data-testid="sibling-modal"
835+
open={isSiblingOpen}
836+
onRequestClose={() => setIsSiblingOpen(false)}>
837+
<button
838+
type="button"
839+
data-testid="launch-child-modal"
840+
onClick={() => setIsChildOpen(true)}>
841+
Launch child modal
842+
</button>
843+
<Modal
844+
data-testid="child-modal"
845+
open={isChildOpen}
846+
onRequestClose={() => setIsChildOpen(false)}
847+
/>
848+
</Modal>
849+
</ModalPresence>
850+
);
851+
};
852+
853+
render(<ModalExample />);
854+
855+
await userEvent.click(screen.getByTestId('launch-sibling-modal'));
856+
await userEvent.click(screen.getByTestId('launch-child-modal'));
857+
858+
expect(screen.queryByTestId('modal')).toBeInTheDocument();
859+
expect(screen.queryByTestId('sibling-modal')).toBeInTheDocument();
860+
expect(screen.queryByTestId('child-modal')).toBeInTheDocument();
861+
862+
await userEvent.keyboard('{Escape}');
863+
864+
expect(screen.queryByTestId('modal')).toBeInTheDocument();
865+
expect(screen.queryByTestId('sibling-modal')).toBeInTheDocument();
866+
expect(screen.queryByTestId('child-modal')).not.toBeInTheDocument();
867+
});
817868
});
818869

819870
const ModalWithPresenceHof = withModalPresence((props) => {

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
ModalPresenceContext,
5454
useExclusiveModalPresenceContext,
5555
} from './ModalPresence';
56+
import { isTopmostVisibleModal } from './isTopmostVisibleModal';
5657

5758
export const ModalSizes = ['xs', 'sm', 'md', 'lg'] as const;
5859
const invalidOutsideClickMessage =
@@ -320,6 +321,7 @@ const ModalDialog = React.forwardRef(function ModalDialog(
320321
const secondaryButton = useRef<HTMLButtonElement>(null);
321322
const contentRef = useRef<HTMLDivElement>(null);
322323
const innerModal = useRef<HTMLDivElement>(null);
324+
const modalRef = useRef<HTMLDivElement>(null);
323325
const startTrap = useRef<HTMLSpanElement>(null);
324326
const endTrap = useRef<HTMLSpanElement>(null);
325327
const wrapFocusTimeout = useRef<NodeJS.Timeout>(null);
@@ -334,7 +336,11 @@ const ModalDialog = React.forwardRef(function ModalDialog(
334336
const loadingActive = loadingStatus !== 'inactive';
335337

336338
const presenceContext = useContext(ModalPresenceContext);
337-
const mergedRefs = useMergedRefs([ref, presenceContext?.presenceRef]);
339+
const mergedRefs = useMergedRefs([
340+
modalRef,
341+
ref,
342+
presenceContext?.presenceRef,
343+
]);
338344
const enablePresence =
339345
useFeatureFlag('enable-presence') || presenceContext?.autoEnablePresence;
340346

@@ -555,7 +561,10 @@ const ModalDialog = React.forwardRef(function ModalDialog(
555561
if (!open) return;
556562

557563
const handleEscapeKey = (event) => {
558-
if (match(event, keys.Escape)) {
564+
if (
565+
match(event, keys.Escape) &&
566+
isTopmostVisibleModal(modalRef.current, prefix)
567+
) {
559568
event.preventDefault();
560569
event.stopPropagation();
561570
onRequestClose(event);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Copyright IBM Corp. 2026
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
export const isTopmostVisibleModal = (
9+
node: HTMLElement | null,
10+
prefix: string
11+
) => {
12+
if (!node) return false;
13+
14+
const visibleModals = document.querySelectorAll(
15+
`.${prefix}--modal.is-visible`
16+
);
17+
18+
return visibleModals.item(visibleModals.length - 1) === node;
19+
};

0 commit comments

Comments
 (0)