Skip to content

Commit a723344

Browse files
committed
fix(modal): handle React template wrapper in child route pointer-events fix
1 parent cc6727a commit a723344

File tree

4 files changed

+156
-12
lines changed

4 files changed

+156
-12
lines changed

core/src/components/modal/modal.tsx

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
100100
private parentRemovalObserver?: MutationObserver;
101101
// Cached original parent from before modal is moved to body during presentation
102102
private cachedOriginalParent?: HTMLElement;
103+
// Elements that had pointer-events disabled for background interaction
104+
private pointerEventsDisabledElements: HTMLElement[] = [];
103105

104106
lastFocus?: HTMLElement;
105107
animation?: Animation;
@@ -763,16 +765,53 @@ export class Modal implements ComponentInterface, OverlayInterface {
763765

764766
/**
765767
* When showBackdrop or focusTrap is false, the modal's original parent may
766-
* block pointer events after the modal is moved to ion-app. Disable
767-
* pointer-events on the parent elements to allow background interaction.
768+
* block pointer events after the modal is moved to ion-app. This only applies
769+
* when the modal is in a child route (detected by the modal being inside
770+
* a route wrapper like ion-page). Disable pointer-events on the child
771+
* route's wrapper elements up to (and including) the first ion-router-outlet.
772+
* We stop there because parent elements may contain sibling content that
773+
* should remain interactive.
768774
* See https://github.com/ionic-team/ionic-framework/issues/30700
769775
*/
770776
if ((this.showBackdrop === false || this.focusTrap === false) && this.cachedOriginalParent) {
771-
this.cachedOriginalParent.style.setProperty('pointer-events', 'none');
777+
// Find the first meaningful parent (skip template and other non-semantic wrappers).
778+
// In Ionic React, modals are wrapped in a <template> element.
779+
let semanticParent: HTMLElement | null = this.cachedOriginalParent;
780+
while (semanticParent && (semanticParent.tagName === 'TEMPLATE' || semanticParent.tagName === 'SLOT')) {
781+
semanticParent = semanticParent.parentElement;
782+
}
783+
784+
// Check if the modal is inside a route wrapper (ion-page or div.ion-page)
785+
// If the modal is inside ion-content or other content containers, this fix doesn't apply
786+
const parentIsRouteWrapper =
787+
semanticParent &&
788+
(semanticParent.tagName === 'ION-PAGE' || semanticParent.classList.contains('ion-page'));
789+
790+
if (parentIsRouteWrapper && semanticParent) {
791+
this.pointerEventsDisabledElements = [];
792+
let current: HTMLElement | null = semanticParent;
793+
794+
while (current && current.tagName !== 'ION-APP') {
795+
const tagName = current.tagName;
796+
// Check for ion-page tag or elements with ion-page class
797+
// (React renders IonPage as div.ion-page, not ion-page tag)
798+
const isIonPage = tagName === 'ION-PAGE' || current.classList.contains('ion-page');
799+
const isRouterOutlet = tagName === 'ION-ROUTER-OUTLET';
800+
const isNav = tagName === 'ION-NAV';
801+
802+
if (isIonPage || isRouterOutlet || isNav) {
803+
current.style.setProperty('pointer-events', 'none');
804+
this.pointerEventsDisabledElements.push(current);
805+
}
772806

773-
const immediateParent = this.cachedOriginalParent.parentElement;
774-
if (immediateParent?.tagName === 'ION-ROUTER-OUTLET') {
775-
immediateParent.style.setProperty('pointer-events', 'none');
807+
// Stop after processing the first ion-router-outlet - parent elements
808+
// may contain sibling content (like buttons) that should remain interactive
809+
if (isRouterOutlet) {
810+
break;
811+
}
812+
813+
current = current.parentElement;
814+
}
776815
}
777816
}
778817
}
@@ -888,13 +927,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
888927
/**
889928
* Clean up pointer-events changes made in initSheetGesture.
890929
*/
891-
if (this.cachedOriginalParent) {
892-
this.cachedOriginalParent.style.removeProperty('pointer-events');
893-
const immediateParent = this.cachedOriginalParent.parentElement;
894-
if (immediateParent?.tagName === 'ION-ROUTER-OUTLET') {
895-
immediateParent.style.removeProperty('pointer-events');
896-
}
930+
for (const element of this.pointerEventsDisabledElements) {
931+
element.style.removeProperty('pointer-events');
897932
}
933+
this.pointerEventsDisabledElements = [];
898934
}
899935
this.currentBreakpoint = undefined;
900936
this.animation = undefined;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React, { useState } from 'react';
2+
import {
3+
IonButton,
4+
IonContent,
5+
IonHeader,
6+
IonModal,
7+
IonPage,
8+
IonRouterOutlet,
9+
IonTitle,
10+
IonToolbar,
11+
} from '@ionic/react';
12+
import { Route } from 'react-router';
13+
14+
/**
15+
* Parent component with counter buttons and nested router outlet.
16+
* This reproduces the issue from https://github.com/ionic-team/ionic-framework/issues/30700
17+
* where sheet modals in child routes with showBackdrop=false block interaction with parent content.
18+
*/
19+
const ModalSheetChildRouteParent: React.FC = () => {
20+
const [count, setCount] = useState(0);
21+
22+
return (
23+
<IonPage>
24+
<IonHeader>
25+
<IonToolbar>
26+
<IonTitle>Parent Page with Nested Route</IonTitle>
27+
</IonToolbar>
28+
</IonHeader>
29+
<IonContent className="ion-padding">
30+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
31+
<IonButton id="decrement-btn" onClick={() => setCount((c) => c - 1)}>
32+
-
33+
</IonButton>
34+
<p id="background-action-count">{count}</p>
35+
<IonButton id="increment-btn" onClick={() => setCount((c) => c + 1)}>
36+
+
37+
</IonButton>
38+
</div>
39+
</IonContent>
40+
<IonRouterOutlet>
41+
<Route path="/overlay-components/modal-sheet-child-route/child" component={ModalSheetChildRouteChild} />
42+
</IonRouterOutlet>
43+
</IonPage>
44+
);
45+
};
46+
47+
const ModalSheetChildRouteChild: React.FC = () => {
48+
return (
49+
<IonPage>
50+
<IonModal
51+
isOpen={true}
52+
breakpoints={[0.2, 0.5, 0.7]}
53+
initialBreakpoint={0.5}
54+
showBackdrop={false}
55+
>
56+
<IonHeader>
57+
<IonToolbar>
58+
<IonTitle>Modal in Child Route</IonTitle>
59+
</IonToolbar>
60+
</IonHeader>
61+
<IonContent className="ion-padding">
62+
<p id="modal-content-loaded">Modal content loaded in child route</p>
63+
</IonContent>
64+
</IonModal>
65+
</IonPage>
66+
);
67+
};
68+
69+
export default ModalSheetChildRouteParent;

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import AlertComponent from './AlertComponent';
1515
import LoadingComponent from './LoadingComponent';
1616
import ModalComponent from './ModalComponent';
1717
import ModalFocusTrap from './ModalFocusTrap';
18+
import ModalSheetChildRoute from './ModalSheetChildRoute';
1819
import ModalTeleport from './ModalTeleport';
1920
import PickerComponent from './PickerComponent';
2021
import PopoverComponent from './PopoverComponent';
@@ -32,6 +33,7 @@ const OverlayHooks: React.FC<OverlayHooksProps> = () => {
3233
<Route path="/overlay-components/loading" component={LoadingComponent} />
3334
<Route path="/overlay-components/modal-basic" component={ModalComponent} />
3435
<Route path="/overlay-components/modal-focus-trap" component={ModalFocusTrap} />
36+
<Route path="/overlay-components/modal-sheet-child-route" component={ModalSheetChildRoute} />
3537
<Route path="/overlay-components/modal-teleport" component={ModalTeleport} />
3638
<Route path="/overlay-components/picker" component={PickerComponent} />
3739
<Route path="/overlay-components/popover" component={PopoverComponent} />
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Tests for sheet modals in child routes with showBackdrop=false.
3+
* See https://github.com/ionic-team/ionic-framework/issues/30700
4+
*/
5+
describe('IonModal: Sheet in Child Route with Nested Routing', () => {
6+
beforeEach(() => {
7+
cy.visit('/overlay-components/modal-sheet-child-route/child');
8+
});
9+
10+
it('should render parent content and child modal', () => {
11+
cy.get('#increment-btn').should('exist');
12+
cy.get('#decrement-btn').should('exist');
13+
cy.get('#background-action-count').should('have.text', '0');
14+
cy.get('ion-modal.show-modal').should('exist');
15+
cy.get('#modal-content-loaded').should('exist');
16+
});
17+
18+
it('should allow interacting with parent content while modal is open in child route', () => {
19+
// Wait for modal to be presented
20+
cy.get('ion-modal.show-modal').should('exist');
21+
22+
// Click the increment button in the parent content
23+
cy.get('#increment-btn').click();
24+
cy.get('#background-action-count').should('have.text', '1');
25+
});
26+
27+
it('should allow multiple interactions with parent content while modal is open', () => {
28+
cy.get('ion-modal.show-modal').should('exist');
29+
30+
cy.get('#increment-btn').click();
31+
cy.get('#increment-btn').click();
32+
cy.get('#background-action-count').should('have.text', '2');
33+
34+
cy.get('#decrement-btn').click();
35+
cy.get('#background-action-count').should('have.text', '1');
36+
});
37+
});

0 commit comments

Comments
 (0)