Skip to content

Commit 63d3985

Browse files
committed
fix(modal): allow background interaction for sheet modals with backdropBreakpoint
1 parent 4454872 commit 63d3985

File tree

4 files changed

+102
-85
lines changed

4 files changed

+102
-85
lines changed

core/src/components/modal/modal.scss

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,6 @@ ion-backdrop {
5757
pointer-events: auto;
5858
}
5959

60-
/**
61-
* When focus trap is disabled on a visible modal, allow clicks to pass through
62-
* to content behind it. The .modal-wrapper retains pointer-events: auto so
63-
* modal content remains interactive. We only apply this when show-modal is
64-
* present to avoid affecting hidden modals that haven't presented.
65-
*/
66-
:host(.ion-disable-focus-trap.show-modal) {
67-
pointer-events: none;
68-
}
69-
70-
:host(.ion-disable-focus-trap.show-modal) ion-backdrop {
71-
pointer-events: none;
72-
}
73-
7460
:host(.overlay-hidden) {
7561
display: none;
7662
}

core/src/components/modal/modal.tsx

Lines changed: 73 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,6 @@ 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[] = [];
105103

106104
lastFocus?: HTMLElement;
107105
animation?: Animation;
@@ -646,14 +644,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
646644
window.addEventListener(KEYBOARD_DID_OPEN, this.keyboardOpenCallback);
647645
}
648646

649-
/**
650-
* Recalculate isSheetModal here because framework bindings (e.g., Angular)
651-
* may not have been applied when componentWillLoad ran.
652-
*/
653-
const isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined;
654-
this.isSheetModal = isSheetModal;
655-
656-
if (isSheetModal) {
647+
if (this.isSheetModal) {
657648
this.initSheetGesture();
658649
} else if (hasCardModal) {
659650
this.initSwipeToClose();
@@ -764,56 +755,82 @@ export class Modal implements ComponentInterface, OverlayInterface {
764755
this.gesture.enable(true);
765756

766757
/**
767-
* When the backdrop doesn't block pointer events (showBackdrop=false,
768-
* focusTrap=false, or backdropBreakpoint > 0), the modal's original parent
769-
* may block pointer events after the modal is moved to ion-app. This only
770-
* applies when the modal is in a child route (detected by the modal being
771-
* inside a route wrapper like ion-page). Disable pointer-events on the child
772-
* route's wrapper elements up to (and including) the first ion-router-outlet.
773-
* We stop there because parent elements may contain sibling content that
774-
* should remain interactive.
758+
* When backdrop interaction is allowed, nested router outlets from child routes
759+
* may block pointer events to parent content. Apply passthrough styles only when
760+
* the modal was the sole content of a child route page.
775761
* See https://github.com/ionic-team/ionic-framework/issues/30700
776762
*/
777-
const backdropNotBlocking = this.showBackdrop === false || this.focusTrap === false || this.backdropBreakpoint > 0;
778-
if (backdropNotBlocking && this.cachedOriginalParent) {
779-
// Find the first meaningful parent (skip template and other non-semantic wrappers).
780-
// In Ionic React, modals are wrapped in a <template> element.
781-
let semanticParent: HTMLElement | null = this.cachedOriginalParent;
782-
while (semanticParent && (semanticParent.tagName === 'TEMPLATE' || semanticParent.tagName === 'SLOT')) {
783-
semanticParent = semanticParent.parentElement;
784-
}
763+
const backdropNotBlocking = this.showBackdrop === false || this.focusTrap === false || backdropBreakpoint > 0;
764+
if (backdropNotBlocking) {
765+
this.setupChildRoutePassthrough();
766+
}
767+
}
785768

786-
// Check if the modal is inside a route wrapper (ion-page or div.ion-page)
787-
// If the modal is inside ion-content or other content containers, this fix doesn't apply
788-
const parentIsRouteWrapper =
789-
semanticParent && (semanticParent.tagName === 'ION-PAGE' || semanticParent.classList.contains('ion-page'));
790-
791-
if (parentIsRouteWrapper && semanticParent) {
792-
this.pointerEventsDisabledElements = [];
793-
let current: HTMLElement | null = semanticParent;
794-
795-
while (current && current.tagName !== 'ION-APP') {
796-
const tagName = current.tagName;
797-
// Check for ion-page tag or elements with ion-page class
798-
// (React renders IonPage as div.ion-page, not ion-page tag)
799-
const isIonPage = tagName === 'ION-PAGE' || current.classList.contains('ion-page');
800-
const isRouterOutlet = tagName === 'ION-ROUTER-OUTLET';
801-
const isNav = tagName === 'ION-NAV';
802-
803-
if (isIonPage || isRouterOutlet || isNav) {
804-
current.style.setProperty('pointer-events', 'none');
805-
this.pointerEventsDisabledElements.push(current);
806-
}
769+
/**
770+
* For sheet modals that allow background interaction, sets up pointer-events
771+
* passthrough on child route page wrappers and nested router outlets.
772+
*/
773+
private setupChildRoutePassthrough() {
774+
const pageParent = this.getOriginalPageParent();
807775

808-
// Stop after processing the first ion-router-outlet - parent elements
809-
// may contain sibling content (like buttons) that should remain interactive
810-
if (isRouterOutlet) {
811-
break;
812-
}
776+
// Skip ion-app (controller modals) and pages with other content (inline modals)
777+
if (!pageParent || pageParent.tagName === 'ION-APP') {
778+
return;
779+
}
813780

814-
current = current.parentElement;
815-
}
816-
}
781+
const hasVisibleContent = Array.from(pageParent.children).some((child) => {
782+
if (child === this.el) return false;
783+
if (child instanceof HTMLElement && window.getComputedStyle(child).display === 'none') return false;
784+
if (child.tagName === 'TEMPLATE' || child.tagName === 'SLOT') return false;
785+
if (child.nodeType === Node.TEXT_NODE && !child.textContent?.trim()) return false;
786+
return true;
787+
});
788+
789+
if (hasVisibleContent) {
790+
return;
791+
}
792+
793+
// Child route case: page only contained the modal
794+
pageParent.classList.add('ion-page-overlay-passthrough');
795+
796+
// Also make nested router outlets passthrough
797+
const routerOutlet = pageParent.parentElement;
798+
if (routerOutlet?.tagName === 'ION-ROUTER-OUTLET' && routerOutlet.parentElement?.tagName !== 'ION-APP') {
799+
routerOutlet.style.setProperty('pointer-events', 'none');
800+
routerOutlet.setAttribute('data-overlay-passthrough', 'true');
801+
}
802+
}
803+
804+
/**
805+
* Finds the ion-page ancestor of the modal's original parent location.
806+
*/
807+
private getOriginalPageParent(): HTMLElement | null {
808+
if (!this.cachedOriginalParent) {
809+
return null;
810+
}
811+
812+
let pageParent: HTMLElement | null = this.cachedOriginalParent;
813+
while (pageParent && !pageParent.classList.contains('ion-page')) {
814+
pageParent = pageParent.parentElement;
815+
}
816+
return pageParent;
817+
}
818+
819+
/**
820+
* Removes passthrough styles added by setupChildRoutePassthrough.
821+
*/
822+
private cleanupChildRoutePassthrough() {
823+
const pageParent = this.getOriginalPageParent();
824+
if (!pageParent) {
825+
return;
826+
}
827+
828+
pageParent.classList.remove('ion-page-overlay-passthrough');
829+
830+
const routerOutlet = pageParent.parentElement;
831+
if (routerOutlet?.hasAttribute('data-overlay-passthrough')) {
832+
routerOutlet.style.removeProperty('pointer-events');
833+
routerOutlet.removeAttribute('data-overlay-passthrough');
817834
}
818835
}
819836

@@ -925,13 +942,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
925942
this.cleanupViewTransitionListener();
926943
this.cleanupParentRemovalObserver();
927944

928-
/**
929-
* Clean up pointer-events changes made in initSheetGesture.
930-
*/
931-
for (const element of this.pointerEventsDisabledElements) {
932-
element.style.removeProperty('pointer-events');
933-
}
934-
this.pointerEventsDisabledElements = [];
945+
this.cleanupChildRoutePassthrough();
935946
}
936947
this.currentBreakpoint = undefined;
937948
this.animation = undefined;

core/src/css/core.scss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@ html.ios ion-modal.modal-card .ion-page {
181181
z-index: $z-index-page-container;
182182
}
183183

184+
/**
185+
* Allows pointer events to pass through child route page wrappers
186+
* when they only contain a sheet modal that permits background interaction.
187+
* https://github.com/ionic-team/ionic-framework/issues/30700
188+
*/
189+
.ion-page.ion-page-overlay-passthrough {
190+
pointer-events: none;
191+
}
192+
184193
/**
185194
* When making custom dialogs, using
186195
* ion-content is not required. As a result,

core/src/utils/overlays.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -539,11 +539,17 @@ export const present = async <OverlayPresentOptions>(
539539
* view container subtree, skip adding aria-hidden/inert there
540540
* to avoid disabling the overlay.
541541
*/
542-
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
542+
const overlayEl = overlay.el as HTMLIonOverlayElement & {
543+
focusTrap?: boolean;
544+
showBackdrop?: boolean;
545+
backdropBreakpoint?: number;
546+
};
543547
const shouldTrapFocus = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false;
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;
548+
// Only lock out root content when backdrop is always active. Developers relying on
549+
// showBackdrop=false or backdropBreakpoint expect background interaction at some point.
550+
const backdropAlwaysActive =
551+
overlayEl.showBackdrop !== false && !((overlayEl.backdropBreakpoint ?? 0) > 0);
552+
const shouldLockRoot = shouldTrapFocus && backdropAlwaysActive;
547553

548554
overlay.presented = true;
549555
overlay.willPresent.emit();
@@ -680,12 +686,17 @@ export const dismiss = async <OverlayDismissOptions>(
680686
* is dismissed.
681687
*/
682688
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;
689+
const el = o as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean; backdropBreakpoint?: number };
690+
const backdropAlwaysActive = el.showBackdrop !== false && !((el.backdropBreakpoint ?? 0) > 0);
691+
return el.tagName !== 'ION-TOAST' && el.focusTrap !== false && backdropAlwaysActive;
685692
});
686-
const overlayEl = overlay.el as HTMLIonOverlayElement & { focusTrap?: boolean; showBackdrop?: boolean };
687-
const locksRoot =
688-
overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && overlayEl.showBackdrop !== false;
693+
const overlayEl = overlay.el as HTMLIonOverlayElement & {
694+
focusTrap?: boolean;
695+
showBackdrop?: boolean;
696+
backdropBreakpoint?: number;
697+
};
698+
const backdropAlwaysActive = overlayEl.showBackdrop !== false && !((overlayEl.backdropBreakpoint ?? 0) > 0);
699+
const locksRoot = overlayEl.tagName !== 'ION-TOAST' && overlayEl.focusTrap !== false && backdropAlwaysActive;
689700

690701
/**
691702
* If this is the last visible overlay that is trapping focus

0 commit comments

Comments
 (0)