Skip to content

Commit 534ad4b

Browse files
committed
fix(overlays): refine safe-area handling for modals and popovers
1 parent 18cce9d commit 534ad4b

File tree

8 files changed

+711
-142
lines changed

8 files changed

+711
-142
lines changed

core/src/components/modal/modal.tsx

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

core/src/components/modal/safe-area-utils.ts

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,32 @@ export interface SafeAreaConfig {
1717
export interface ModalSafeAreaContext {
1818
isSheetModal: boolean;
1919
isCardModal: boolean;
20-
mode: 'ios' | 'md';
2120
presentingElement?: HTMLElement;
2221
breakpoints?: number[];
2322
currentBreakpoint?: number;
2423
}
2524

26-
const TABLET_WIDTH = 768;
25+
/**
26+
* These thresholds match the SCSS media query breakpoints in modal.vars.scss
27+
* that trigger the centered dialog layout (non-fullscreen modal).
28+
*
29+
* SCSS defines two height breakpoints: $modal-inset-min-height-small (600px)
30+
* and $modal-inset-min-height-large (768px). We use the smaller one because
31+
* that's the threshold where the modal transitions from fullscreen to centered
32+
* dialog — the larger breakpoint only increases the dialog's height.
33+
*/
34+
const MODAL_INSET_MIN_WIDTH = 768;
35+
const MODAL_INSET_MIN_HEIGHT = 600;
2736
const EDGE_THRESHOLD = 5;
2837

2938
/**
30-
* Determines if the current viewport is tablet-sized (>= 768px width).
39+
* Determines if the current viewport meets the CSS media query conditions
40+
* that cause regular modals to render as centered dialogs instead of fullscreen.
41+
* Matches: @media (min-width: 768px) and (min-height: 600px)
3142
*/
32-
export const isTabletViewport = (): boolean => {
33-
return win ? win.innerWidth >= TABLET_WIDTH : false;
43+
const isCenteredDialogViewport = (): boolean => {
44+
if (!win) return false;
45+
return win.matchMedia(`(min-width: ${MODAL_INSET_MIN_WIDTH}px) and (min-height: ${MODAL_INSET_MIN_HEIGHT}px)`).matches;
3446
};
3547

3648
/**
@@ -41,20 +53,21 @@ export const isTabletViewport = (): boolean => {
4153
* @returns SafeAreaConfig with initial safe-area values
4254
*/
4355
export const getInitialSafeAreaConfig = (context: ModalSafeAreaContext): SafeAreaConfig => {
44-
const { isSheetModal, isCardModal, mode } = context;
56+
const { isSheetModal, isCardModal } = context;
4557

46-
// Sheet modals always use bottom safe-area only
58+
// Sheet modals use bottom safe-area, and top safe-area only when fully expanded
4759
if (isSheetModal) {
4860
return {
49-
top: '0px',
61+
top: context.currentBreakpoint === 1 ? 'inherit' : '0px',
5062
bottom: 'inherit',
5163
left: '0px',
5264
right: '0px',
5365
};
5466
}
5567

56-
// Card modals (iOS only) need safe-area for height calculation
57-
if (isCardModal && mode === 'ios') {
68+
// Card modals need safe-area for height calculation.
69+
// Note: isCardModal is already gated on mode === 'ios' by the caller.
70+
if (isCardModal) {
5871
return {
5972
top: 'inherit',
6073
bottom: 'inherit',
@@ -63,7 +76,19 @@ export const getInitialSafeAreaConfig = (context: ModalSafeAreaContext): SafeAre
6376
};
6477
}
6578

66-
// Fullscreen and centered dialogs - inherit all, let position detection decide
79+
// On viewports that meet the centered dialog media query breakpoints,
80+
// regular modals render as centered dialogs (not fullscreen), so they
81+
// don't touch any screen edges and don't need safe-area insets.
82+
if (isCenteredDialogViewport()) {
83+
return {
84+
top: '0px',
85+
bottom: '0px',
86+
left: '0px',
87+
right: '0px',
88+
};
89+
}
90+
91+
// Fullscreen modals on phone - inherit all safe areas
6792
return {
6893
top: 'inherit',
6994
bottom: 'inherit',
@@ -76,25 +101,16 @@ export const getInitialSafeAreaConfig = (context: ModalSafeAreaContext): SafeAre
76101
* Returns safe-area configuration based on actual modal position.
77102
* Detects which edges the modal overlaps with and only applies safe-area to those edges.
78103
*
104+
* Note: On Android edge-to-edge (API 36+), getBoundingClientRect() may report
105+
* inconsistent values. Sheet and card modals avoid this by using configuration-based
106+
* prediction instead. Regular modals use coordinate detection which works reliably
107+
* on web and iOS; Android edge-to-edge may need a configuration-based fallback
108+
* once a reliable detection mechanism is available.
109+
*
79110
* @param wrapperEl - The modal wrapper element to measure
80-
* @param skipCoordinateDetection - If true, skips coordinate detection and returns inherit for all
81111
* @returns SafeAreaConfig based on position
82112
*/
83-
export const getPositionBasedSafeAreaConfig = (
84-
wrapperEl: HTMLElement,
85-
skipCoordinateDetection = false
86-
): SafeAreaConfig => {
87-
// Skip coordinate detection for Android edge-to-edge compatibility
88-
// or when explicitly requested
89-
if (skipCoordinateDetection) {
90-
return {
91-
top: 'inherit',
92-
bottom: 'inherit',
93-
left: 'inherit',
94-
right: 'inherit',
95-
};
96-
}
97-
113+
export const getPositionBasedSafeAreaConfig = (wrapperEl: HTMLElement): SafeAreaConfig => {
98114
const rect = wrapperEl.getBoundingClientRect();
99115
const vh = win?.innerHeight ?? 0;
100116
const vw = win?.innerWidth ?? 0;

0 commit comments

Comments
 (0)