@@ -44,6 +44,14 @@ import type { MoveSheetToBreakpointOptions } from './gestures/sheet';
4444import { createSheetGesture } from './gestures/sheet' ;
4545import { createSwipeToCloseGesture , SwipeToCloseDefaults } from './gestures/swipe-to-close' ;
4646import type { ModalBreakpointChangeEventDetail , ModalHandleBehavior } from './modal-interface' ;
47+ import {
48+ getInitialSafeAreaConfig ,
49+ getPositionBasedSafeAreaConfig ,
50+ applySafeAreaOverrides ,
51+ clearSafeAreaOverrides ,
52+ getRootSafeAreaTop ,
53+ type ModalSafeAreaContext ,
54+ } from './safe-area-utils' ;
4755import { setCardStatusBarDark , setCardStatusBarDefault } from './utils' ;
4856
4957// TODO(FW-2832): types
@@ -276,14 +284,35 @@ export class Modal implements ComponentInterface, OverlayInterface {
276284
277285 @Listen ( 'resize' , { target : 'window' } )
278286 onWindowResize ( ) {
279- // Only handle resize for iOS card modals when no custom animations are provided
280- if ( getIonMode ( this ) !== 'ios' || ! this . presentingElement || this . enterAnimation || this . leaveAnimation ) {
281- return ;
282- }
287+ if ( ! this . presented ) return ;
283288
284289 clearTimeout ( this . resizeTimeout ) ;
285290 this . resizeTimeout = setTimeout ( ( ) => {
286- this . handleViewTransition ( ) ;
291+ const context = this . getSafeAreaContext ( ) ;
292+
293+ // iOS card modals: handle portrait/landscape view transitions
294+ if ( context . isCardModal && ! this . enterAnimation && ! this . leaveAnimation ) {
295+ this . handleViewTransition ( ) ;
296+ }
297+
298+ // Sheet modals: re-compute the internal offset property since safe-area
299+ // values may change on device rotation (e.g., portrait notch vs landscape).
300+ if ( context . isSheetModal ) {
301+ this . updateSheetOffsetTop ( ) ;
302+ }
303+
304+ // Regular (non-sheet, non-card) modals: update safe-area overrides
305+ // since the viewport may have crossed the centered-dialog breakpoint.
306+ if ( ! context . isSheetModal && ! context . isCardModal ) {
307+ this . updateSafeAreaOverrides ( ) ;
308+
309+ // Re-evaluate fullscreen safe-area padding: clear first, then re-apply
310+ if ( this . wrapperEl ) {
311+ this . wrapperEl . style . removeProperty ( 'height' ) ;
312+ this . wrapperEl . style . removeProperty ( 'padding-bottom' ) ;
313+ }
314+ this . applyFullscreenSafeArea ( ) ;
315+ }
287316 } , 50 ) ; // Debounce to avoid excessive calls during active resizing
288317 }
289318
@@ -406,6 +435,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
406435 this . triggerController . removeClickListener ( ) ;
407436 this . cleanupViewTransitionListener ( ) ;
408437 this . cleanupParentRemovalObserver ( ) ;
438+ // Also called in dismiss() — intentional dual cleanup covers both
439+ // dismiss-then-remove and direct DOM removal without dismiss.
440+ this . cleanupSafeAreaOverrides ( ) ;
409441 }
410442
411443 componentWillLoad ( ) {
@@ -594,6 +626,13 @@ export class Modal implements ComponentInterface, OverlayInterface {
594626
595627 writeTask ( ( ) => this . el . classList . add ( 'show-modal' ) ) ;
596628
629+ // Recalculate isSheetModal before safe-area setup because framework
630+ // bindings (e.g., Angular) may not have been applied when componentWillLoad ran.
631+ this . isSheetModal = this . breakpoints !== undefined && this . initialBreakpoint !== undefined ;
632+
633+ // Set initial safe-area overrides before animation
634+ this . setInitialSafeAreaOverrides ( ) ;
635+
597636 const hasCardModal = presentingElement !== undefined ;
598637
599638 /**
@@ -614,6 +653,12 @@ export class Modal implements ComponentInterface, OverlayInterface {
614653 expandToScroll : this . expandToScroll ,
615654 } ) ;
616655
656+ // Update safe-area based on actual position after animation
657+ this . updateSafeAreaOverrides ( ) ;
658+
659+ // Apply fullscreen safe-area padding if needed
660+ this . applyFullscreenSafeArea ( ) ;
661+
617662 /* tslint:disable-next-line */
618663 if ( typeof window !== 'undefined' ) {
619664 /**
@@ -646,14 +691,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
646691 window . addEventListener ( KEYBOARD_DID_OPEN , this . keyboardOpenCallback ) ;
647692 }
648693
649- /**
650- * Recalculate isSheetModal 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 ) {
694+ if ( this . isSheetModal ) {
657695 this . initSheetGesture ( ) ;
658696 } else if ( hasCardModal ) {
659697 this . initSwipeToClose ( ) ;
@@ -885,6 +923,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
885923 return false ;
886924 }
887925
926+ // Cancel any pending resize timeout to prevent stale updates during dismiss
927+ clearTimeout ( this . resizeTimeout ) ;
928+ this . resizeTimeout = undefined ;
929+
888930 /**
889931 * Because the canDismiss check below is async,
890932 * we need to claim a lock before the check happens,
@@ -956,6 +998,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
956998 }
957999 this . cleanupViewTransitionListener ( ) ;
9581000 this . cleanupParentRemovalObserver ( ) ;
1001+ this . cleanupSafeAreaOverrides ( ) ;
9591002
9601003 this . cleanupChildRoutePassthrough ( ) ;
9611004 }
@@ -1166,6 +1209,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
11661209 transitionAnimation . play ( ) . then ( ( ) => {
11671210 this . viewTransitionAnimation = undefined ;
11681211
1212+ // Wait for a layout pass after the transition so getBoundingClientRect()
1213+ // in getPositionBasedSafeAreaConfig() reflects the new dimensions.
1214+ raf ( ( ) => this . updateSafeAreaOverrides ( ) ) ;
1215+
11691216 // After orientation transition, recreate the swipe-to-close gesture
11701217 // with updated animation that reflects the new presenting element state
11711218 this . reinitSwipeToClose ( ) ;
@@ -1335,6 +1382,130 @@ export class Modal implements ComponentInterface, OverlayInterface {
13351382 this . parentRemovalObserver = undefined ;
13361383 }
13371384
1385+ /**
1386+ * Creates the context object for safe-area utilities.
1387+ */
1388+ private getSafeAreaContext ( ) : ModalSafeAreaContext {
1389+ return {
1390+ isSheetModal : this . isSheetModal ,
1391+ isCardModal : this . presentingElement !== undefined && getIonMode ( this ) === 'ios' ,
1392+ presentingElement : this . presentingElement ,
1393+ breakpoints : this . breakpoints ,
1394+ currentBreakpoint : this . currentBreakpoint ,
1395+ } ;
1396+ }
1397+
1398+ /**
1399+ * Sets initial safe-area overrides before modal animation.
1400+ * Called in present() before animation starts.
1401+ *
1402+ * For sheet modals, the SCSS --height formula uses --ion-modal-offset-top
1403+ * (an internal property) instead of --ion-safe-area-top. We resolve the
1404+ * root safe-area-top to pixels and set --ion-modal-offset-top, decoupling
1405+ * the height calculation from --ion-safe-area-top (which is zeroed for
1406+ * sheets to prevent header content from getting double-offset padding).
1407+ */
1408+ private setInitialSafeAreaOverrides ( ) : void {
1409+ const context = this . getSafeAreaContext ( ) ;
1410+ const safeAreaConfig = getInitialSafeAreaConfig ( context ) ;
1411+ applySafeAreaOverrides ( this . el , safeAreaConfig ) ;
1412+
1413+ // Set the internal offset property with the resolved root safe-area-top value
1414+ if ( context . isSheetModal ) {
1415+ this . updateSheetOffsetTop ( ) ;
1416+ }
1417+ }
1418+
1419+ /**
1420+ * Resolves the current root --ion-safe-area-top value and sets the
1421+ * internal --ion-modal-offset-top property on the host element.
1422+ * Called on present and on resize (e.g., device rotation changes safe-area).
1423+ */
1424+ private updateSheetOffsetTop ( ) : void {
1425+ const safeAreaTop = getRootSafeAreaTop ( ) ;
1426+ this . el . style . setProperty ( '--ion-modal-offset-top' , `${ safeAreaTop } px` ) ;
1427+ }
1428+
1429+ /**
1430+ * Updates safe-area overrides during dynamic state changes.
1431+ * Called after animations, during gestures, and on orientation changes.
1432+ */
1433+ private updateSafeAreaOverrides ( ) : void {
1434+ const { wrapperEl, el } = this ;
1435+ const context = this . getSafeAreaContext ( ) ;
1436+
1437+ // Sheet modals: safe-area is fully determined at presentation time
1438+ // (top is always 0px, height is frozen). Nothing to update.
1439+ if ( context . isSheetModal ) return ;
1440+
1441+ // Card modals have fixed safe-area requirements set by initial prediction.
1442+ if ( context . isCardModal ) return ;
1443+
1444+ // wrapperEl is required for position-based detection below
1445+ if ( ! wrapperEl ) return ;
1446+
1447+ // Regular modals: use position-based detection to correctly handle both
1448+ // fullscreen modals and centered dialogs with custom dimensions.
1449+ const safeAreaConfig = getPositionBasedSafeAreaConfig ( wrapperEl ) ;
1450+ applySafeAreaOverrides ( el , safeAreaConfig ) ;
1451+ }
1452+
1453+ /**
1454+ * Applies padding-bottom to fullscreen modal wrapper to prevent
1455+ * content from overlapping system navigation bar.
1456+ */
1457+ private applyFullscreenSafeArea ( ) : void {
1458+ const { wrapperEl, el } = this ;
1459+ if ( ! wrapperEl ) return ;
1460+
1461+ const context = this . getSafeAreaContext ( ) ;
1462+ if ( context . isSheetModal || context . isCardModal ) return ;
1463+
1464+ // Check for standard Ionic layout children (ion-content, ion-footer),
1465+ // searching one level deep for wrapped components (e.g.,
1466+ // <app-footer><ion-footer>...</ion-footer></app-footer>).
1467+ // Note: uses a manual loop instead of querySelector(':scope > ...') because
1468+ // Stencil's mock-doc (used in spec tests) does not support :scope.
1469+ let hasContent = false ;
1470+ let hasFooter = false ;
1471+ for ( const child of Array . from ( el . children ) ) {
1472+ if ( child . tagName === 'ION-CONTENT' ) hasContent = true ;
1473+ if ( child . tagName === 'ION-FOOTER' ) hasFooter = true ;
1474+ for ( const grandchild of Array . from ( child . children ) ) {
1475+ if ( grandchild . tagName === 'ION-CONTENT' ) hasContent = true ;
1476+ if ( grandchild . tagName === 'ION-FOOTER' ) hasFooter = true ;
1477+ }
1478+ }
1479+
1480+ // Only apply wrapper padding for standard Ionic layouts (has ion-content
1481+ // but no ion-footer). Custom modals with raw HTML are fully
1482+ // developer-controlled and should not be modified.
1483+ if ( ! hasContent || hasFooter ) return ;
1484+
1485+ // Reduce wrapper height by safe-area and add equivalent padding so the
1486+ // total visual size stays the same but the flex content area shrinks.
1487+ // Using height + padding instead of box-sizing: border-box avoids
1488+ // breaking custom modals that set --border-width (border-box would
1489+ // include the border inside the height, changing the layout).
1490+ wrapperEl . style . setProperty ( 'height' , 'calc(var(--height) - var(--ion-safe-area-bottom, 0px))' ) ;
1491+ wrapperEl . style . setProperty ( 'padding-bottom' , 'var(--ion-safe-area-bottom, 0px)' ) ;
1492+ }
1493+
1494+ /**
1495+ * Clears all safe-area overrides and padding from wrapper.
1496+ */
1497+ private cleanupSafeAreaOverrides ( ) : void {
1498+ clearSafeAreaOverrides ( this . el ) ;
1499+
1500+ // Remove internal sheet offset property
1501+ this . el . style . removeProperty ( '--ion-modal-offset-top' ) ;
1502+
1503+ if ( this . wrapperEl ) {
1504+ this . wrapperEl . style . removeProperty ( 'height' ) ;
1505+ this . wrapperEl . style . removeProperty ( 'padding-bottom' ) ;
1506+ }
1507+ }
1508+
13381509 render ( ) {
13391510 const {
13401511 handle,
0 commit comments