@@ -413,6 +413,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
413413 this . triggerController . removeClickListener ( ) ;
414414 this . cleanupViewTransitionListener ( ) ;
415415 this . cleanupParentRemovalObserver ( ) ;
416+ // Also called in dismiss() — intentional dual cleanup covers both
417+ // dismiss-then-remove and direct DOM removal without dismiss.
418+ this . cleanupSafeAreaOverrides ( ) ;
416419 }
417420
418421 componentWillLoad ( ) {
@@ -601,6 +604,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
601604
602605 writeTask ( ( ) => this . el . classList . add ( 'show-modal' ) ) ;
603606
607+ // Recalculate isSheetModal before safe-area setup because framework
608+ // bindings (e.g., Angular) may not have been applied when componentWillLoad ran.
609+ this . isSheetModal = this . breakpoints !== undefined && this . initialBreakpoint !== undefined ;
610+
604611 // Set initial safe-area overrides before animation
605612 this . setInitialSafeAreaOverrides ( ) ;
606613
@@ -662,14 +669,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
662669 window . addEventListener ( KEYBOARD_DID_OPEN , this . keyboardOpenCallback ) ;
663670 }
664671
665- /**
666- * Recalculate isSheetModal because framework bindings (e.g., Angular)
667- * may not have been applied when componentWillLoad ran.
668- */
669- const isSheetModal = this . breakpoints !== undefined && this . initialBreakpoint !== undefined ;
670- this . isSheetModal = isSheetModal ;
671-
672- if ( isSheetModal ) {
672+ if ( this . isSheetModal ) {
673673 this . initSheetGesture ( ) ;
674674 } else if ( hasCardModal ) {
675675 this . initSwipeToClose ( ) ;
@@ -1185,8 +1185,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
11851185 transitionAnimation . play ( ) . then ( ( ) => {
11861186 this . viewTransitionAnimation = undefined ;
11871187
1188- // Update safe-area overrides for new orientation
1189- this . updateSafeAreaOverrides ( ) ;
1188+ // Wait for a layout pass after the transition so getBoundingClientRect()
1189+ // in getPositionBasedSafeAreaConfig() reflects the new dimensions.
1190+ raf ( ( ) => this . updateSafeAreaOverrides ( ) ) ;
11901191
11911192 // After orientation transition, recreate the swipe-to-close gesture
11921193 // with updated animation that reflects the new presenting element state
@@ -1361,11 +1362,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
13611362 * Creates the context object for safe-area utilities.
13621363 */
13631364 private getSafeAreaContext ( ) : ModalSafeAreaContext {
1364- const mode = getIonMode ( this ) ;
13651365 return {
13661366 isSheetModal : this . isSheetModal ,
1367- isCardModal : this . presentingElement !== undefined && mode === 'ios' ,
1368- mode,
1367+ isCardModal : this . presentingElement !== undefined && getIonMode ( this ) === 'ios' ,
13691368 presentingElement : this . presentingElement ,
13701369 breakpoints : this . breakpoints ,
13711370 currentBreakpoint : this . currentBreakpoint ,
@@ -1378,8 +1377,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
13781377 */
13791378 private setInitialSafeAreaOverrides ( ) : void {
13801379 const context = this . getSafeAreaContext ( ) ;
1381- const config = getInitialSafeAreaConfig ( context ) ;
1382- applySafeAreaOverrides ( this . el , config ) ;
1380+ const safeAreaConfig = getInitialSafeAreaConfig ( context ) ;
1381+ applySafeAreaOverrides ( this . el , safeAreaConfig ) ;
13831382 }
13841383
13851384 /**
@@ -1388,12 +1387,32 @@ export class Modal implements ComponentInterface, OverlayInterface {
13881387 */
13891388 private updateSafeAreaOverrides ( ) : void {
13901389 const { wrapperEl, el } = this ;
1390+ const context = this . getSafeAreaContext ( ) ;
1391+
1392+ // Sheet modals: the wrapper extends beyond the viewport and is translated
1393+ // via breakpoint gestures, making getBoundingClientRect unreliable for
1394+ // edge detection. Instead, use breakpoint value to determine top safe-area.
1395+ if ( context . isSheetModal ) {
1396+ const needsTopSafeArea = context . currentBreakpoint === 1 ;
1397+ applySafeAreaOverrides ( el , {
1398+ top : needsTopSafeArea ? 'inherit' : '0px' ,
1399+ bottom : 'inherit' ,
1400+ left : '0px' ,
1401+ right : '0px' ,
1402+ } ) ;
1403+ return ;
1404+ }
1405+
1406+ // Card modals have fixed safe-area requirements set by initial prediction.
1407+ if ( context . isCardModal ) return ;
1408+
1409+ // wrapperEl is required for position-based detection below
13911410 if ( ! wrapperEl ) return ;
13921411
1393- // Always use position-based detection to correctly handle both
1394- // fullscreen modals and centered dialogs with custom dimensions
1395- const config = getPositionBasedSafeAreaConfig ( wrapperEl ) ;
1396- applySafeAreaOverrides ( el , config ) ;
1412+ // Regular modals: use position-based detection to correctly handle both
1413+ // fullscreen modals and centered dialogs with custom dimensions.
1414+ const safeAreaConfig = getPositionBasedSafeAreaConfig ( wrapperEl ) ;
1415+ applySafeAreaOverrides ( el , safeAreaConfig ) ;
13971416 }
13981417
13991418 /**
@@ -1407,12 +1426,31 @@ export class Modal implements ComponentInterface, OverlayInterface {
14071426 const context = this . getSafeAreaContext ( ) ;
14081427 if ( context . isSheetModal || context . isCardModal ) return ;
14091428
1410- // Check if footer exists
1411- const footer = el . querySelector ( 'ion-footer' ) ;
1412- if ( footer ) return ;
1429+ // Check for standard Ionic layout children (ion-content, ion-footer),
1430+ // searching one level deep for wrapped components (e.g.,
1431+ // <app-footer><ion-footer>...</ion-footer></app-footer>).
1432+ let hasContent = false ;
1433+ let hasFooter = false ;
1434+ for ( const child of Array . from ( el . children ) ) {
1435+ if ( child . tagName === 'ION-CONTENT' ) hasContent = true ;
1436+ if ( child . tagName === 'ION-FOOTER' ) hasFooter = true ;
1437+ for ( const grandchild of Array . from ( child . children ) ) {
1438+ if ( grandchild . tagName === 'ION-CONTENT' ) hasContent = true ;
1439+ if ( grandchild . tagName === 'ION-FOOTER' ) hasFooter = true ;
1440+ }
1441+ }
14131442
1414- // Add padding to wrapper to prevent content overlap
1415- wrapperEl . style . setProperty ( 'box-sizing' , 'border-box' ) ;
1443+ // Only apply wrapper padding for standard Ionic layouts (has ion-content
1444+ // but no ion-footer). Custom modals with raw HTML are fully
1445+ // developer-controlled and should not be modified.
1446+ if ( ! hasContent || hasFooter ) return ;
1447+
1448+ // Reduce wrapper height by safe-area and add equivalent padding so the
1449+ // total visual size stays the same but the flex content area shrinks.
1450+ // Using height + padding instead of box-sizing: border-box avoids
1451+ // breaking custom modals that set --border-width (border-box would
1452+ // include the border inside the height, changing the layout).
1453+ wrapperEl . style . setProperty ( 'height' , 'calc(var(--height) - var(--ion-safe-area-bottom, 0px))' ) ;
14161454 wrapperEl . style . setProperty ( 'padding-bottom' , 'var(--ion-safe-area-bottom, 0px)' ) ;
14171455 }
14181456
@@ -1423,7 +1461,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
14231461 clearSafeAreaOverrides ( this . el ) ;
14241462
14251463 if ( this . wrapperEl ) {
1426- this . wrapperEl . style . removeProperty ( 'box-sizing ' ) ;
1464+ this . wrapperEl . style . removeProperty ( 'height ' ) ;
14271465 this . wrapperEl . style . removeProperty ( 'padding-bottom' ) ;
14281466 }
14291467 }
0 commit comments