Skip to content

Commit 3557925

Browse files
committed
fix(modal): dynamically handle safe-area insets for edge-to-edge mode
1 parent 61b588c commit 3557925

6 files changed

Lines changed: 393 additions & 29 deletions

File tree

core/src/components/modal/modal.tsx

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
102102
private cachedOriginalParent?: HTMLElement;
103103
// Cached ion-page ancestor for child route passthrough
104104
private cachedPageParent?: HTMLElement | null;
105+
// Whether to skip coordinate-based safe-area detection (for fullscreen phone modals)
106+
private skipSafeAreaCoordinateDetection = false;
105107

106108
lastFocus?: HTMLElement;
107109
animation?: Animation;
@@ -877,42 +879,61 @@ export class Modal implements ComponentInterface, OverlayInterface {
877879
const isCardModal = presentingElement !== undefined;
878880
const isTablet = window.innerWidth >= 768;
879881

880-
// Sheet modals: always touch bottom, top depends on breakpoint
882+
// Sheet modals always touch bottom edge, never top/left/right
881883
if (isSheetModal) {
882884
style.setProperty('--ion-safe-area-top', '0px');
883-
// Don't override bottom - sheet always touches bottom
884885
style.setProperty('--ion-safe-area-left', '0px');
885886
style.setProperty('--ion-safe-area-right', '0px');
886887
return;
887888
}
888889

889-
// Card modals are inset from edges (rounded corners), no safe areas needed
890+
// Card modals are inset from all edges
890891
if (isCardModal) {
891-
style.setProperty('--ion-safe-area-top', '0px');
892-
style.setProperty('--ion-safe-area-bottom', '0px');
893-
style.setProperty('--ion-safe-area-left', '0px');
894-
style.setProperty('--ion-safe-area-right', '0px');
892+
this.zeroAllSafeAreas();
895893
return;
896894
}
897895

898-
// Phone modals are fullscreen, need all safe areas
896+
// Phone-sized fullscreen modals inherit safe areas and use wrapper padding
899897
if (!isTablet) {
900-
// Don't set any overrides - inherit from :root
898+
this.applyFullscreenSafeArea();
901899
return;
902900
}
903901

904-
// Default tablet modal: centered dialog, no safe areas needed
905-
// Check for fullscreen override via CSS custom properties
902+
// Check if tablet modal is fullscreen via CSS custom properties
906903
const computedStyle = getComputedStyle(this.el);
907904
const width = computedStyle.getPropertyValue('--width').trim();
908905
const height = computedStyle.getPropertyValue('--height').trim();
906+
const isFullscreen = width === '100%' && height === '100%';
909907

910-
if (width === '100%' && height === '100%') {
911-
// Fullscreen modal - need safe areas, don't override
912-
return;
908+
if (isFullscreen) {
909+
this.applyFullscreenSafeArea();
910+
} else {
911+
// Centered dialog doesn't touch edges
912+
this.zeroAllSafeAreas();
913+
}
914+
}
915+
916+
/**
917+
* Applies safe-area handling for fullscreen modals.
918+
* Adds wrapper padding when no footer is present to prevent
919+
* content from overlapping system navigation areas.
920+
*/
921+
private applyFullscreenSafeArea() {
922+
this.skipSafeAreaCoordinateDetection = true;
923+
924+
const hasFooter = this.el.querySelector('ion-footer') !== null;
925+
if (!hasFooter && this.wrapperEl) {
926+
this.wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
927+
this.wrapperEl.style.setProperty('box-sizing', 'border-box');
913928
}
929+
}
914930

915-
// Centered dialog - zero out all safe areas
931+
/**
932+
* Sets all safe-area CSS variables to 0px for modals that
933+
* don't touch screen edges.
934+
*/
935+
private zeroAllSafeAreas() {
936+
const style = this.el.style;
916937
style.setProperty('--ion-safe-area-top', '0px');
917938
style.setProperty('--ion-safe-area-bottom', '0px');
918939
style.setProperty('--ion-safe-area-left', '0px');
@@ -921,22 +942,27 @@ export class Modal implements ComponentInterface, OverlayInterface {
921942

922943
/**
923944
* Updates safe-area CSS variable overrides based on whether the modal
924-
* is touching each edge of the viewport. This is called after animation
945+
* is touching each edge of the viewport. Called after animation
925946
* and during gestures to handle dynamic position changes.
926947
*/
927948
private updateSafeAreaOverrides() {
949+
if (this.skipSafeAreaCoordinateDetection) {
950+
return;
951+
}
952+
928953
const wrapper = this.wrapperEl;
929-
if (!wrapper) return;
954+
if (!wrapper) {
955+
return;
956+
}
930957

931958
const rect = wrapper.getBoundingClientRect();
932-
const threshold = 2; // Account for subpixel rendering
959+
const threshold = 2;
933960

934961
const touchingTop = rect.top <= threshold;
935962
const touchingBottom = rect.bottom >= window.innerHeight - threshold;
936963
const touchingLeft = rect.left <= threshold;
937964
const touchingRight = rect.right >= window.innerWidth - threshold;
938965

939-
// Remove override when touching edge (allow inheritance), set to 0 when not touching
940966
const style = this.el.style;
941967
touchingTop ? style.removeProperty('--ion-safe-area-top') : style.setProperty('--ion-safe-area-top', '0px');
942968
touchingBottom
@@ -1058,6 +1084,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
10581084
}
10591085
this.currentBreakpoint = undefined;
10601086
this.animation = undefined;
1087+
// Reset safe-area detection flag for potential re-presentation
1088+
this.skipSafeAreaCoordinateDetection = false;
10611089

10621090
unlock();
10631091

core/src/components/popover/animations/ios.enter.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
6161
top,
6262
left,
6363
bottom,
64+
checkSafeAreaTop,
65+
checkSafeAreaBottom,
6466
checkSafeAreaLeft,
6567
checkSafeAreaRight,
6668
arrowTop,
@@ -118,23 +120,39 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
118120
baseEl.classList.add('popover-bottom');
119121
}
120122

121-
if (bottom !== undefined) {
122-
contentEl.style.setProperty('bottom', `${bottom}px`);
123-
}
124-
123+
/**
124+
* Safe area CSS variable adjustments.
125+
* When the popover is positioned near an edge, we add the corresponding
126+
* safe-area inset to ensure the popover doesn't overlap with system UI
127+
* (status bars, home indicators, navigation bars on Android API 36+, etc.)
128+
*/
129+
const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
130+
const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
125131
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
126132
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
127133

134+
let topValue = `${top}px`;
135+
let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
128136
let leftValue = `${left}px`;
129137

138+
if (checkSafeAreaTop) {
139+
topValue = `${top}px${safeAreaTop}`;
140+
}
141+
if (checkSafeAreaBottom && bottomValue !== undefined) {
142+
bottomValue = `${bottom}px${safeAreaBottom}`;
143+
}
130144
if (checkSafeAreaLeft) {
131145
leftValue = `${left}px${safeAreaLeft}`;
132146
}
133147
if (checkSafeAreaRight) {
134148
leftValue = `${left}px${safeAreaRight}`;
135149
}
136150

137-
contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
151+
if (bottomValue !== undefined) {
152+
contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
153+
}
154+
155+
contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0))`);
138156
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
139157
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
140158

core/src/components/popover/animations/md.enter.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
4747

4848
const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
4949

50-
const { originX, originY, top, left, bottom } = calculateWindowAdjustment(
50+
const { originX, originY, top, left, bottom, checkSafeAreaTop, checkSafeAreaBottom } = calculateWindowAdjustment(
5151
side,
5252
results.top,
5353
results.left,
@@ -62,6 +62,25 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
6262
results.referenceCoordinates
6363
);
6464

65+
/**
66+
* Safe area CSS variable adjustments.
67+
* When the popover is positioned near an edge, we add the corresponding
68+
* safe-area inset to ensure the popover doesn't overlap with system UI
69+
* (status bars, home indicators, navigation bars on Android API 36+, etc.)
70+
*/
71+
const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
72+
const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
73+
74+
let topValue = `${top}px`;
75+
let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
76+
77+
if (checkSafeAreaTop) {
78+
topValue = `${top}px${safeAreaTop}`;
79+
}
80+
if (checkSafeAreaBottom && bottomValue !== undefined) {
81+
bottomValue = `${bottom}px${safeAreaBottom}`;
82+
}
83+
6584
const baseAnimation = createAnimation();
6685
const backdropAnimation = createAnimation();
6786
const wrapperAnimation = createAnimation();
@@ -81,13 +100,13 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
81100
contentAnimation
82101
.addElement(contentEl)
83102
.beforeStyles({
84-
top: `calc(${top}px + var(--offset-y, 0px))`,
103+
top: `calc(${topValue} + var(--offset-y, 0px))`,
85104
left: `calc(${left}px + var(--offset-x, 0px))`,
86105
'transform-origin': `${originY} ${originX}`,
87106
})
88107
.beforeAddWrite(() => {
89-
if (bottom !== undefined) {
90-
contentEl.style.setProperty('bottom', `${bottom}px`);
108+
if (bottomValue !== undefined) {
109+
contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
91110
}
92111
})
93112
.fromTo('transform', 'scale(0.8)', 'scale(1)');

0 commit comments

Comments
 (0)