Skip to content

Commit 6490797

Browse files
ShaneKIonitron
andauthored
fix(modal, popover): respect safe area insets on popovers and modals (#30949)
Issue number: resolves #28411 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? When a modal is displayed on tablet-sized screens (>= 768px × >= 600px), the `--ion-safe-area-*` CSS variables are explicitly set to 0px. This was intended for inset modals that don't touch screen edges, but it breaks safe area handling on newer iPads with Face ID/home indicators, causing content to overlap with system UI elements. ## What is the new behavior? Modals now dynamically handle safe-area insets based on their type and position. This has to be done because modals that don't touch the edges cannot have a safe area applied (because it will add unnecessary padding), but modals that do touch the edges need to apply safe area correctly or the edges will be obstructed by whatever is in the safe area. ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> [Modals test page](https://ionic-framework-git-fw-6830-2-ionic1.vercel.app/src/components/modal/test/safe-area/index.html) [Popovers test page](https://ionic-framework-git-fw-6830-2-ionic1.vercel.app/src/components/popover/test/safe-area/index.html) Current dev build: ``` 8.7.18-dev.11770674094.18396f54 ``` --------- Co-authored-by: ionitron <hi@ionicframework.com>
1 parent c8a65dc commit 6490797

30 files changed

+1454
-52
lines changed
102 Bytes
Loading
11 Bytes
Loading

core/src/components/modal/modal.scss

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,6 @@ ion-backdrop {
9494
:host {
9595
--width: #{$modal-inset-width};
9696
--height: #{$modal-inset-height-small};
97-
--ion-safe-area-top: 0px;
98-
--ion-safe-area-bottom: 0px;
99-
--ion-safe-area-right: 0px;
100-
--ion-safe-area-left: 0px;
10197
}
10298
}
10399

@@ -156,9 +152,14 @@ ion-backdrop {
156152
/**
157153
* Ensure that the sheet modal does not
158154
* completely cover the content.
155+
*
156+
* --ion-modal-offset-top is an internal property set by modal.tsx
157+
* with the resolved root safe-area-top pixel value. This decouples
158+
* the height calculation from --ion-safe-area-top (which is zeroed
159+
* for sheet modals to prevent header double-padding).
159160
*/
160161
:host(.modal-sheet) {
161-
--height: calc(100% - (var(--ion-safe-area-top) + 10px));
162+
--height: calc(100% - (var(--ion-modal-offset-top, 0px) + 10px));
162163
}
163164

164165
:host(.modal-sheet) .modal-wrapper,

core/src/components/modal/modal.tsx

Lines changed: 184 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ import type { MoveSheetToBreakpointOptions } from './gestures/sheet';
4444
import { createSheetGesture } from './gestures/sheet';
4545
import { createSwipeToCloseGesture, SwipeToCloseDefaults } from './gestures/swipe-to-close';
4646
import 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';
4755
import { 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

Comments
 (0)