Skip to content

Commit a9751a6

Browse files
committed
fix(modal): dynamically apply safe-area insets based on viewport edge contact
1 parent c54f257 commit a9751a6

File tree

5 files changed

+371
-31
lines changed

5 files changed

+371
-31
lines changed

core/src/components/modal/gestures/sheet.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ export const createSheetGesture = (
5252
expandToScroll: boolean,
5353
getCurrentBreakpoint: () => number,
5454
onDismiss: () => void,
55-
onBreakpointChange: (breakpoint: number) => void
55+
onBreakpointChange: (breakpoint: number) => void,
56+
onGestureMove?: () => void
5657
) => {
5758
// Defaults for the sheet swipe animation
5859
const defaultBackdrop = [
@@ -423,6 +424,9 @@ export const createSheetGesture = (
423424

424425
offset = clamp(0.0001, processedStep, maxStep);
425426
animation.progressStep(offset);
427+
428+
// Notify modal of position change for safe-area updates
429+
onGestureMove?.();
426430
};
427431

428432
const onEnd = (detail: GestureDetail) => {

core/src/components/modal/gestures/swipe-to-close.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export const createSwipeToCloseGesture = (
2020
el: HTMLIonModalElement,
2121
animation: Animation,
2222
statusBarStyle: StatusBarStyle,
23-
onDismiss: () => void
23+
onDismiss: () => void,
24+
onGestureMove?: () => void
2425
) => {
2526
/**
2627
* The step value at which a card modal
@@ -199,6 +200,9 @@ export const createSwipeToCloseGesture = (
199200

200201
animation.progressStep(clampedStep);
201202

203+
// Notify modal of position change for safe-area updates
204+
onGestureMove?.();
205+
202206
/**
203207
* When swiping down half way, the status bar style
204208
* should be reset to its default value.

core/src/components/modal/modal.tsx

Lines changed: 68 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
276276

277277
@Listen('resize', { target: 'window' })
278278
onWindowResize() {
279-
// Only handle resize for iOS card modals when no custom animations are provided
279+
// Update safe-area overrides for all modal types on resize
280+
this.updateSafeAreaOverrides();
281+
282+
// Only handle view transition for iOS card modals when no custom animations are provided
280283
if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
281284
return;
282285
}
@@ -659,6 +662,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
659662
this.initSwipeToClose();
660663
}
661664

665+
// Set initial safe-area overrides based on modal position
666+
this.updateSafeAreaOverrides();
667+
662668
// Initialize view transition listener for iOS card modals
663669
this.initViewTransitionListener();
664670

@@ -692,33 +698,39 @@ export class Modal implements ComponentInterface, OverlayInterface {
692698

693699
const statusBarStyle = this.statusBarStyle ?? StatusBarStyle.Default;
694700

695-
this.gesture = createSwipeToCloseGesture(el, ani, statusBarStyle, () => {
696-
/**
697-
* While the gesture animation is finishing
698-
* it is possible for a user to tap the backdrop.
699-
* This would result in the dismiss animation
700-
* being played again. Typically this is avoided
701-
* by setting `presented = false` on the overlay
702-
* component; however, we cannot do that here as
703-
* that would prevent the element from being
704-
* removed from the DOM.
705-
*/
706-
this.gestureAnimationDismissing = true;
707-
708-
/**
709-
* Reset the status bar style as the dismiss animation
710-
* starts otherwise the status bar will be the wrong
711-
* color for the duration of the dismiss animation.
712-
* The dismiss method does this as well, but
713-
* in this case it's only called once the animation
714-
* has finished.
715-
*/
716-
setCardStatusBarDefault(this.statusBarStyle);
717-
this.animation!.onFinish(async () => {
718-
await this.dismiss(undefined, GESTURE);
719-
this.gestureAnimationDismissing = false;
720-
});
721-
});
701+
this.gesture = createSwipeToCloseGesture(
702+
el,
703+
ani,
704+
statusBarStyle,
705+
() => {
706+
/**
707+
* While the gesture animation is finishing
708+
* it is possible for a user to tap the backdrop.
709+
* This would result in the dismiss animation
710+
* being played again. Typically this is avoided
711+
* by setting `presented = false` on the overlay
712+
* component; however, we cannot do that here as
713+
* that would prevent the element from being
714+
* removed from the DOM.
715+
*/
716+
this.gestureAnimationDismissing = true;
717+
718+
/**
719+
* Reset the status bar style as the dismiss animation
720+
* starts otherwise the status bar will be the wrong
721+
* color for the duration of the dismiss animation.
722+
* The dismiss method does this as well, but
723+
* in this case it's only called once the animation
724+
* has finished.
725+
*/
726+
setCardStatusBarDefault(this.statusBarStyle);
727+
this.animation!.onFinish(async () => {
728+
await this.dismiss(undefined, GESTURE);
729+
this.gestureAnimationDismissing = false;
730+
});
731+
},
732+
() => this.updateSafeAreaOverrides()
733+
);
722734
this.gesture.enable(true);
723735
}
724736

@@ -755,7 +767,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
755767
this.currentBreakpoint = breakpoint;
756768
this.ionBreakpointDidChange.emit({ breakpoint });
757769
}
758-
}
770+
this.updateSafeAreaOverrides();
771+
},
772+
() => this.updateSafeAreaOverrides()
759773
);
760774

761775
this.gesture = gesture;
@@ -849,6 +863,31 @@ export class Modal implements ComponentInterface, OverlayInterface {
849863
this.cachedPageParent = undefined;
850864
}
851865

866+
/**
867+
* Updates safe-area CSS variable overrides based on whether the modal
868+
* is touching each edge of the viewport. This ensures that modals which
869+
* don't touch an edge (e.g., centered dialogs) don't have unnecessary
870+
* safe-area padding, while full-screen modals properly respect safe areas.
871+
*/
872+
private updateSafeAreaOverrides() {
873+
const wrapper = this.wrapperEl;
874+
if (!wrapper) return;
875+
876+
const rect = wrapper.getBoundingClientRect();
877+
const threshold = 2; // Account for subpixel rendering
878+
879+
const touchingTop = rect.top <= threshold;
880+
const touchingBottom = rect.bottom >= window.innerHeight - threshold;
881+
const touchingLeft = rect.left <= threshold;
882+
const touchingRight = rect.right >= window.innerWidth - threshold;
883+
884+
// Zero out safe-area for edges not touching the viewport (null removes the override)
885+
this.el.style.setProperty('--ion-safe-area-top', touchingTop ? null : '0px');
886+
this.el.style.setProperty('--ion-safe-area-bottom', touchingBottom ? null : '0px');
887+
this.el.style.setProperty('--ion-safe-area-left', touchingLeft ? null : '0px');
888+
this.el.style.setProperty('--ion-safe-area-right', touchingRight ? null : '0px');
889+
}
890+
852891
private sheetOnDismiss() {
853892
/**
854893
* While the gesture animation is finishing
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Modal - Safe Area</title>
6+
<meta
7+
name="viewport"
8+
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
14+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
15+
<style>
16+
/**
17+
* Simulate safe-area insets for testing.
18+
* These values represent typical iPad safe areas.
19+
*/
20+
:root {
21+
--ion-safe-area-top: 44px;
22+
--ion-safe-area-bottom: 34px;
23+
--ion-safe-area-left: 0px;
24+
--ion-safe-area-right: 0px;
25+
}
26+
27+
.fullscreen-modal {
28+
--width: 100%;
29+
--height: 100%;
30+
}
31+
</style>
32+
</head>
33+
34+
<body>
35+
<ion-app>
36+
<div class="ion-page" id="main-page">
37+
<ion-header>
38+
<ion-toolbar>
39+
<ion-title>Modal - Safe Area</ion-title>
40+
</ion-toolbar>
41+
</ion-header>
42+
43+
<ion-content class="ion-padding">
44+
<p>Test safe-area handling in modals on tablet-sized screens.</p>
45+
46+
<ion-list>
47+
<ion-item>
48+
<ion-label>
49+
<h2>Default Modal</h2>
50+
<p>Centered dialog on tablet - should NOT have safe-area padding</p>
51+
</ion-label>
52+
<ion-button slot="end" id="default-modal" onclick="presentDefaultModal()">Present</ion-button>
53+
</ion-item>
54+
55+
<ion-item>
56+
<ion-label>
57+
<h2>Fullscreen Modal</h2>
58+
<p>Full screen on tablet - should have safe-area padding</p>
59+
</ion-label>
60+
<ion-button slot="end" id="fullscreen-modal" onclick="presentFullscreenModal()">Present</ion-button>
61+
</ion-item>
62+
63+
<ion-item>
64+
<ion-label>
65+
<h2>Sheet Modal (Partial)</h2>
66+
<p>At 0.5 breakpoint - should have bottom safe-area only</p>
67+
</ion-label>
68+
<ion-button slot="end" id="sheet-modal-partial" onclick="presentSheetModalPartial()">Present</ion-button>
69+
</ion-item>
70+
71+
<ion-item>
72+
<ion-label>
73+
<h2>Sheet Modal (Full)</h2>
74+
<p>At 1.0 breakpoint - should have top and bottom safe-area</p>
75+
</ion-label>
76+
<ion-button slot="end" id="sheet-modal-full" onclick="presentSheetModalFull()">Present</ion-button>
77+
</ion-item>
78+
79+
<ion-item>
80+
<ion-label>
81+
<h2>Card Modal (iOS)</h2>
82+
<p>Card presentation - should have safe-area padding</p>
83+
</ion-label>
84+
<ion-button slot="end" id="card-modal" onclick="presentCardModal()">Present</ion-button>
85+
</ion-item>
86+
</ion-list>
87+
</ion-content>
88+
</div>
89+
</ion-app>
90+
91+
<script>
92+
function createModalContent(title) {
93+
const element = document.createElement('div');
94+
element.innerHTML = `
95+
<ion-header>
96+
<ion-toolbar>
97+
<ion-title>${title}</ion-title>
98+
<ion-buttons slot="end">
99+
<ion-button class="dismiss" onclick="dismissModal()">Close</ion-button>
100+
</ion-buttons>
101+
</ion-toolbar>
102+
</ion-header>
103+
<ion-content class="ion-padding">
104+
<h1>Modal Content</h1>
105+
<p>This modal tests safe-area handling.</p>
106+
<p>The header should respect safe-area-top when the modal touches the top edge.</p>
107+
<p>The footer should respect safe-area-bottom when the modal touches the bottom edge.</p>
108+
</ion-content>
109+
<ion-footer>
110+
<ion-toolbar>
111+
<ion-title>Footer</ion-title>
112+
</ion-toolbar>
113+
</ion-footer>
114+
`;
115+
return element;
116+
}
117+
118+
let currentModal = null;
119+
120+
async function dismissModal() {
121+
if (currentModal) {
122+
await currentModal.dismiss();
123+
currentModal = null;
124+
}
125+
}
126+
127+
async function presentDefaultModal() {
128+
currentModal = Object.assign(document.createElement('ion-modal'), {
129+
component: createModalContent('Default Modal'),
130+
});
131+
document.body.appendChild(currentModal);
132+
await currentModal.present();
133+
}
134+
135+
async function presentFullscreenModal() {
136+
currentModal = Object.assign(document.createElement('ion-modal'), {
137+
component: createModalContent('Fullscreen Modal'),
138+
});
139+
currentModal.classList.add('fullscreen-modal');
140+
document.body.appendChild(currentModal);
141+
await currentModal.present();
142+
}
143+
144+
async function presentSheetModalPartial() {
145+
currentModal = Object.assign(document.createElement('ion-modal'), {
146+
component: createModalContent('Sheet Modal (Partial)'),
147+
initialBreakpoint: 0.5,
148+
breakpoints: [0, 0.25, 0.5, 0.75, 1],
149+
});
150+
document.body.appendChild(currentModal);
151+
await currentModal.present();
152+
}
153+
154+
async function presentSheetModalFull() {
155+
currentModal = Object.assign(document.createElement('ion-modal'), {
156+
component: createModalContent('Sheet Modal (Full)'),
157+
initialBreakpoint: 1,
158+
breakpoints: [0, 0.5, 1],
159+
});
160+
document.body.appendChild(currentModal);
161+
await currentModal.present();
162+
}
163+
164+
async function presentCardModal() {
165+
const presentingElement = document.getElementById('main-page');
166+
currentModal = Object.assign(document.createElement('ion-modal'), {
167+
component: createModalContent('Card Modal'),
168+
presentingElement: presentingElement,
169+
});
170+
document.body.appendChild(currentModal);
171+
await currentModal.present();
172+
}
173+
</script>
174+
</body>
175+
</html>

0 commit comments

Comments
 (0)