Skip to content

Commit 435d7e2

Browse files
committed
fix(overlay): fixing issues with teleported modals
1 parent 3b1da5d commit 435d7e2

File tree

7 files changed

+244
-18
lines changed

7 files changed

+244
-18
lines changed

core/src/components/modal/gestures/sheet.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,9 @@ export const createSheetGesture = (
9696

9797
const enableBackdrop = () => {
9898
// Respect explicit opt-out of focus trapping/backdrop interactions
99-
// If focusTrap is false, do not enable the backdrop or re-enable focus trap
100-
if ((baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap === false) {
99+
// If focusTrap is false or showBackdrop is false, do not enable the backdrop or re-enable focus trap
100+
const el = baseEl as HTMLIonModalElement & { focusTrap?: boolean; showBackdrop?: boolean };
101+
if (el.focusTrap === false || el.showBackdrop === false) {
101102
return;
102103
}
103104
baseEl.style.setProperty('pointer-events', 'auto');
@@ -242,7 +243,8 @@ export const createSheetGesture = (
242243
*/
243244
const shouldEnableBackdrop =
244245
currentBreakpoint > backdropBreakpoint &&
245-
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false;
246+
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false &&
247+
(baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false;
246248
if (shouldEnableBackdrop) {
247249
enableBackdrop();
248250
} else {
@@ -591,7 +593,8 @@ export const createSheetGesture = (
591593
*/
592594
const shouldEnableBackdrop =
593595
currentBreakpoint > backdropBreakpoint &&
594-
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false;
596+
(baseEl as HTMLIonModalElement & { focusTrap?: boolean }).focusTrap !== false &&
597+
(baseEl as HTMLIonModalElement & { showBackdrop?: boolean }).showBackdrop !== false;
595598
if (shouldEnableBackdrop) {
596599
enableBackdrop();
597600
} else {

core/src/utils/overlays.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -494,10 +494,8 @@ export const setRootAriaHidden = (hidden = false) => {
494494

495495
if (hidden) {
496496
viewContainer.setAttribute('aria-hidden', 'true');
497-
viewContainer.setAttribute('inert', '');
498497
} else {
499498
viewContainer.removeAttribute('aria-hidden');
500-
viewContainer.removeAttribute('inert');
501499
}
502500
};
503501

@@ -532,17 +530,34 @@ export const present = async <OverlayPresentOptions>(
532530
* the keyboard focus from leaving the overlay unless
533531
* developers explicitly opt out (for example, sheet
534532
* modals that should permit background interaction).
533+
*
534+
* Note: Some apps move inline overlays to a specific container
535+
* during the willPresent lifecycle (e.g., React portals via
536+
* onWillPresent). Defer applying aria-hidden/inert to the app
537+
* root until after willPresent so we can detect where the
538+
* overlay is finally inserted. If the overlay is inside the
539+
* view container subtree, skip adding aria-hidden/inert there
540+
* to avoid disabling the overlay.
535541
*/
536-
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean };
542+
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
537543
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false;
538-
539-
if (shouldTrapFocus) {
540-
setRootAriaHidden(true);
541-
document.body.classList.add(BACKDROP_NO_SCROLL);
542-
}
544+
// Only lock out root content when backdrop is active. Developers relying on showBackdrop=false
545+
// expect background interaction to remain enabled.
546+
const shouldLockRoot = shouldTrapFocus && overlayEl.showBackdrop !== false;
543547

544548
overlay.presented = true;
545549
overlay.willPresent.emit();
550+
551+
if (shouldLockRoot) {
552+
const root = getAppRoot(document);
553+
const viewContainer = root.querySelector('ion-router-outlet, #ion-view-container-root');
554+
const overlayInsideViewContainer = viewContainer ? viewContainer.contains(overlayEl) : false;
555+
556+
if (!overlayInsideViewContainer) {
557+
setRootAriaHidden(true);
558+
}
559+
document.body.classList.add(BACKDROP_NO_SCROLL);
560+
}
546561
overlay.willPresentShorthand?.emit();
547562

548563
const mode = getIonMode(overlay);
@@ -664,18 +679,19 @@ export const dismiss = async <OverlayDismissOptions>(
664679
* from the root element when the last focus-trapping overlay
665680
* is dismissed.
666681
*/
667-
const overlaysTrappingFocus = presentedOverlays.filter(
668-
(o) => o.tagName !== 'ION-TOAST' && (o as any).focusTrap !== false
669-
);
670-
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean };
671-
const trapsFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false;
682+
const overlaysLockingRoot = presentedOverlays.filter((o) => {
683+
const el = o as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
684+
return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && el.showBackdrop !== false;
685+
});
686+
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
687+
const locksRoot = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false;
672688

673689
/**
674690
* If this is the last visible overlay that is trapping focus
675691
* then we want to re-add the root to the accessibility tree.
676692
*/
677693
const lastOverlayTrappingFocus =
678-
trapsFocus && overlaysTrappingFocus.length === 1 && overlaysTrappingFocus[0].id === overlayEl.id;
694+
locksRoot && overlaysLockingRoot.length === 1 && overlaysLockingRoot[0].id === overlayEl.id;
679695

680696
if (lastOverlayTrappingFocus) {
681697
setRootAriaHidden(false);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React, { useState } from 'react';
2+
import { IonButton, IonContent, IonModal, IonPage } from '@ionic/react';
3+
4+
const ModalFocusTrap: React.FC = () => {
5+
const [showNonTrapped, setShowNonTrapped] = useState(false);
6+
const [showTrapped, setShowTrapped] = useState(false);
7+
const [count, setCount] = useState(0);
8+
9+
return (
10+
<IonPage>
11+
<IonContent className="ion-padding">
12+
<IonButton id="open-non-trapped-modal" onClick={() => setShowNonTrapped(true)}>
13+
Open Non-Trapped Sheet Modal
14+
</IonButton>
15+
<IonButton id="open-trapped-modal" color="primary" onClick={() => setShowTrapped(true)}>
16+
Open Focus-Trapped Sheet Modal
17+
</IonButton>
18+
19+
<IonButton id="background-action" onClick={() => setCount((c) => c + 1)}>
20+
Background Action
21+
</IonButton>
22+
<div>
23+
Background action count: <span id="background-action-count">{count}</span>
24+
</div>
25+
26+
<IonModal
27+
isOpen={showNonTrapped}
28+
onDidDismiss={() => setShowNonTrapped(false)}
29+
breakpoints={[0, 0.25, 0.5, 0.75, 1]}
30+
initialBreakpoint={0.25}
31+
backdropDismiss={false}
32+
focusTrap={false}
33+
handleBehavior="cycle"
34+
>
35+
<IonContent className="ion-padding">
36+
<p>Non-trapped modal content</p>
37+
<IonButton onClick={() => setShowNonTrapped(false)}>Close</IonButton>
38+
</IonContent>
39+
</IonModal>
40+
41+
<IonModal
42+
isOpen={showTrapped}
43+
onDidDismiss={() => setShowTrapped(false)}
44+
breakpoints={[0, 0.25, 0.5, 0.75, 1]}
45+
initialBreakpoint={0.5}
46+
backdropDismiss={false}
47+
focusTrap={true}
48+
handleBehavior="cycle"
49+
>
50+
<IonContent className="ion-padding">
51+
<p>Focus-trapped modal content</p>
52+
<IonButton onClick={() => setShowTrapped(false)}>Close</IonButton>
53+
</IonContent>
54+
</IonModal>
55+
</IonContent>
56+
</IonPage>
57+
);
58+
};
59+
60+
export default ModalFocusTrap;
61+
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import React, { useState } from 'react';
2+
import {
3+
IonButton,
4+
IonButtons,
5+
IonContent,
6+
IonHeader,
7+
IonModal,
8+
IonPage,
9+
IonTitle,
10+
IonToolbar,
11+
} from '@ionic/react';
12+
13+
const ModalTeleport: React.FC = () => {
14+
const [isOpen, setIsOpen] = useState(false);
15+
const [count, setCount] = useState(0);
16+
17+
return (
18+
<IonPage>
19+
<IonContent className="ion-padding">
20+
<div id="example" style={{ minHeight: '40vh' }}></div>
21+
22+
<IonButton id="teleport-background-action" onClick={() => setCount((c) => c + 1)}>
23+
Background Action
24+
</IonButton>
25+
<div>
26+
Background action count: <span id="teleport-background-action-count">{count}</span>
27+
</div>
28+
29+
<IonButton id="open-teleport-modal" onClick={() => setIsOpen(true)}>
30+
Open Teleported Modal
31+
</IonButton>
32+
33+
{isOpen && (
34+
<IonModal
35+
isOpen={true}
36+
onDidDismiss={() => setIsOpen(false)}
37+
onWillPresent={(event) => {
38+
const container = document.getElementById('example');
39+
if (container) {
40+
container.appendChild(event.target as HTMLElement);
41+
}
42+
}}
43+
breakpoints={[0.2, 0.5, 0.7]}
44+
initialBreakpoint={0.5}
45+
showBackdrop={false}
46+
>
47+
<IonHeader>
48+
<IonToolbar>
49+
<IonTitle>Modal</IonTitle>
50+
<IonButtons slot="end">
51+
<IonButton id="close-teleport-modal" onClick={() => setIsOpen(false)}>
52+
Close
53+
</IonButton>
54+
</IonButtons>
55+
</IonToolbar>
56+
</IonHeader>
57+
<IonContent className="ion-padding">
58+
<p>
59+
Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni illum quidem recusandae ducimus quos
60+
reprehenderit. Veniam, molestias quos, dolorum consequuntur nisi deserunt omnis id illo sit cum qui.
61+
Eaque, dicta.
62+
</p>
63+
</IonContent>
64+
</IonModal>
65+
)}
66+
</IonContent>
67+
</IonPage>
68+
);
69+
};
70+
71+
export default ModalTeleport;

packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import ActionSheetComponent from './ActionSheetComponent';
1414
import AlertComponent from './AlertComponent';
1515
import LoadingComponent from './LoadingComponent';
1616
import ModalComponent from './ModalComponent';
17+
import ModalFocusTrap from './ModalFocusTrap';
18+
import ModalTeleport from './ModalTeleport';
1719
import PickerComponent from './PickerComponent';
1820
import PopoverComponent from './PopoverComponent';
1921
import ToastComponent from './ToastComponent';
@@ -29,6 +31,8 @@ const OverlayHooks: React.FC<OverlayHooksProps> = () => {
2931
<Route path="/overlay-components/alert" component={AlertComponent} />
3032
<Route path="/overlay-components/loading" component={LoadingComponent} />
3133
<Route path="/overlay-components/modal" component={ModalComponent} />
34+
<Route path="/overlay-components/modal-focus-trap" component={ModalFocusTrap} />
35+
<Route path="/overlay-components/modal-teleport" component={ModalTeleport} />
3236
<Route path="/overlay-components/picker" component={PickerComponent} />
3337
<Route path="/overlay-components/popover" component={PopoverComponent} />
3438
<Route path="/overlay-components/toast" component={ToastComponent} />
@@ -50,6 +54,14 @@ const OverlayHooks: React.FC<OverlayHooksProps> = () => {
5054
<IonIcon icon={star} />
5155
<IonLabel>Modal</IonLabel>
5256
</IonTabButton>
57+
<IonTabButton tab="modalFocus" href="/overlay-components/modal-focus-trap">
58+
<IonIcon icon={star} />
59+
<IonLabel>Modal Focus</IonLabel>
60+
</IonTabButton>
61+
<IonTabButton tab="modalTeleport" href="/overlay-components/modal-teleport">
62+
<IonIcon icon={star} />
63+
<IonLabel>Modal Teleport</IonLabel>
64+
</IonTabButton>
5365
<IonTabButton tab="picker" href="/overlay-components/picker">
5466
<IonIcon icon={logoIonic} />
5567
<IonLabel>Picker</IonLabel>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
describe('IonModal: focusTrap regression', () => {
2+
beforeEach(() => {
3+
cy.visit('/overlay-components/modal-focus-trap');
4+
});
5+
6+
it('should allow interacting with background when focusTrap=false', () => {
7+
cy.get('#open-non-trapped-modal').click();
8+
cy.get('ion-modal').should('be.visible');
9+
10+
cy.get('#background-action').click();
11+
cy.get('#background-action-count').should('have.text', '1');
12+
});
13+
14+
it('should prevent interacting with background when focusTrap=true', () => {
15+
cy.get('#open-trapped-modal').click();
16+
cy.get('ion-modal').should('be.visible');
17+
18+
// Ensure backdrop is active and capturing pointer events
19+
cy.get('ion-backdrop').should('exist');
20+
cy.get('ion-backdrop').should('have.css', 'pointer-events', 'auto');
21+
22+
// Baseline: counter is 0
23+
cy.get('#background-action-count').should('have.text', '0');
24+
25+
// Click the center of the background button via body coordinates (topmost element will receive it)
26+
cy.get('#background-action').then(($btn) => {
27+
const rect = $btn[0].getBoundingClientRect();
28+
const x = rect.left + rect.width / 2;
29+
const y = rect.top + rect.height / 2;
30+
cy.get('body').click(x, y);
31+
});
32+
33+
// Counter should remain unchanged
34+
cy.get('#background-action-count').should('have.text', '0');
35+
});
36+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
describe('IonModal: inline teleport with showBackdrop=false', () => {
2+
beforeEach(() => {
3+
cy.visit('/overlay-components/modal-teleport');
4+
});
5+
6+
it('should render and remain interactive when appended into a page container', () => {
7+
cy.get('#open-teleport-modal').click();
8+
cy.get('ion-modal').should('be.visible');
9+
10+
// Verify modal content is interactable: close button should dismiss the modal
11+
cy.get('#close-teleport-modal').click();
12+
cy.get('ion-modal').should('not.exist');
13+
});
14+
15+
it('should allow background interaction when showBackdrop=false', () => {
16+
cy.get('#open-teleport-modal').click();
17+
cy.get('ion-modal').should('be.visible');
18+
19+
// Ensure the background button is clickable while modal is open
20+
cy.get('#teleport-background-action').click();
21+
cy.get('#teleport-background-action-count').should('have.text', '1');
22+
23+
// Cleanup
24+
cy.get('#close-teleport-modal').click();
25+
cy.get('ion-modal').should('not.exist');
26+
});
27+
});

0 commit comments

Comments
 (0)