fix(modal): dynamically handle safe-area insets for edge-to-edge mode

This commit is contained in:
ShaneK
2025-12-31 10:28:09 -08:00
parent 61b588c6b9
commit 35579250d5
6 changed files with 393 additions and 29 deletions

View File

@@ -102,6 +102,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
private cachedOriginalParent?: HTMLElement;
// Cached ion-page ancestor for child route passthrough
private cachedPageParent?: HTMLElement | null;
// Whether to skip coordinate-based safe-area detection (for fullscreen phone modals)
private skipSafeAreaCoordinateDetection = false;
lastFocus?: HTMLElement;
animation?: Animation;
@@ -877,42 +879,61 @@ export class Modal implements ComponentInterface, OverlayInterface {
const isCardModal = presentingElement !== undefined;
const isTablet = window.innerWidth >= 768;
// Sheet modals: always touch bottom, top depends on breakpoint
// Sheet modals always touch bottom edge, never top/left/right
if (isSheetModal) {
style.setProperty('--ion-safe-area-top', '0px');
// Don't override bottom - sheet always touches bottom
style.setProperty('--ion-safe-area-left', '0px');
style.setProperty('--ion-safe-area-right', '0px');
return;
}
// Card modals are inset from edges (rounded corners), no safe areas needed
// Card modals are inset from all edges
if (isCardModal) {
style.setProperty('--ion-safe-area-top', '0px');
style.setProperty('--ion-safe-area-bottom', '0px');
style.setProperty('--ion-safe-area-left', '0px');
style.setProperty('--ion-safe-area-right', '0px');
this.zeroAllSafeAreas();
return;
}
// Phone modals are fullscreen, need all safe areas
// Phone-sized fullscreen modals inherit safe areas and use wrapper padding
if (!isTablet) {
// Don't set any overrides - inherit from :root
this.applyFullscreenSafeArea();
return;
}
// Default tablet modal: centered dialog, no safe areas needed
// Check for fullscreen override via CSS custom properties
// Check if tablet modal is fullscreen via CSS custom properties
const computedStyle = getComputedStyle(this.el);
const width = computedStyle.getPropertyValue('--width').trim();
const height = computedStyle.getPropertyValue('--height').trim();
const isFullscreen = width === '100%' && height === '100%';
if (width === '100%' && height === '100%') {
// Fullscreen modal - need safe areas, don't override
return;
if (isFullscreen) {
this.applyFullscreenSafeArea();
} else {
// Centered dialog doesn't touch edges
this.zeroAllSafeAreas();
}
}
// Centered dialog - zero out all safe areas
/**
* Applies safe-area handling for fullscreen modals.
* Adds wrapper padding when no footer is present to prevent
* content from overlapping system navigation areas.
*/
private applyFullscreenSafeArea() {
this.skipSafeAreaCoordinateDetection = true;
const hasFooter = this.el.querySelector('ion-footer') !== null;
if (!hasFooter && this.wrapperEl) {
this.wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
this.wrapperEl.style.setProperty('box-sizing', 'border-box');
}
}
/**
* Sets all safe-area CSS variables to 0px for modals that
* don't touch screen edges.
*/
private zeroAllSafeAreas() {
const style = this.el.style;
style.setProperty('--ion-safe-area-top', '0px');
style.setProperty('--ion-safe-area-bottom', '0px');
style.setProperty('--ion-safe-area-left', '0px');
@@ -921,22 +942,27 @@ export class Modal implements ComponentInterface, OverlayInterface {
/**
* Updates safe-area CSS variable overrides based on whether the modal
* is touching each edge of the viewport. This is called after animation
* is touching each edge of the viewport. Called after animation
* and during gestures to handle dynamic position changes.
*/
private updateSafeAreaOverrides() {
if (this.skipSafeAreaCoordinateDetection) {
return;
}
const wrapper = this.wrapperEl;
if (!wrapper) return;
if (!wrapper) {
return;
}
const rect = wrapper.getBoundingClientRect();
const threshold = 2; // Account for subpixel rendering
const threshold = 2;
const touchingTop = rect.top <= threshold;
const touchingBottom = rect.bottom >= window.innerHeight - threshold;
const touchingLeft = rect.left <= threshold;
const touchingRight = rect.right >= window.innerWidth - threshold;
// Remove override when touching edge (allow inheritance), set to 0 when not touching
const style = this.el.style;
touchingTop ? style.removeProperty('--ion-safe-area-top') : style.setProperty('--ion-safe-area-top', '0px');
touchingBottom
@@ -1058,6 +1084,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
this.currentBreakpoint = undefined;
this.animation = undefined;
// Reset safe-area detection flag for potential re-presentation
this.skipSafeAreaCoordinateDetection = false;
unlock();

View File

@@ -61,6 +61,8 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
top,
left,
bottom,
checkSafeAreaTop,
checkSafeAreaBottom,
checkSafeAreaLeft,
checkSafeAreaRight,
arrowTop,
@@ -118,15 +120,27 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
baseEl.classList.add('popover-bottom');
}
if (bottom !== undefined) {
contentEl.style.setProperty('bottom', `${bottom}px`);
}
/**
* Safe area CSS variable adjustments.
* When the popover is positioned near an edge, we add the corresponding
* safe-area inset to ensure the popover doesn't overlap with system UI
* (status bars, home indicators, navigation bars on Android API 36+, etc.)
*/
const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
let topValue = `${top}px`;
let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
let leftValue = `${left}px`;
if (checkSafeAreaTop) {
topValue = `${top}px${safeAreaTop}`;
}
if (checkSafeAreaBottom && bottomValue !== undefined) {
bottomValue = `${bottom}px${safeAreaBottom}`;
}
if (checkSafeAreaLeft) {
leftValue = `${left}px${safeAreaLeft}`;
}
@@ -134,7 +148,11 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
leftValue = `${left}px${safeAreaRight}`;
}
contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
if (bottomValue !== undefined) {
contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
}
contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0))`);
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);

View File

@@ -47,7 +47,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
const { originX, originY, top, left, bottom } = calculateWindowAdjustment(
const { originX, originY, top, left, bottom, checkSafeAreaTop, checkSafeAreaBottom } = calculateWindowAdjustment(
side,
results.top,
results.left,
@@ -62,6 +62,25 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
results.referenceCoordinates
);
/**
* Safe area CSS variable adjustments.
* When the popover is positioned near an edge, we add the corresponding
* safe-area inset to ensure the popover doesn't overlap with system UI
* (status bars, home indicators, navigation bars on Android API 36+, etc.)
*/
const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
let topValue = `${top}px`;
let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
if (checkSafeAreaTop) {
topValue = `${top}px${safeAreaTop}`;
}
if (checkSafeAreaBottom && bottomValue !== undefined) {
bottomValue = `${bottom}px${safeAreaBottom}`;
}
const baseAnimation = createAnimation();
const backdropAnimation = createAnimation();
const wrapperAnimation = createAnimation();
@@ -81,13 +100,13 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
contentAnimation
.addElement(contentEl)
.beforeStyles({
top: `calc(${top}px + var(--offset-y, 0px))`,
top: `calc(${topValue} + var(--offset-y, 0px))`,
left: `calc(${left}px + var(--offset-x, 0px))`,
'transform-origin': `${originY} ${originX}`,
})
.beforeAddWrite(() => {
if (bottom !== undefined) {
contentEl.style.setProperty('bottom', `${bottom}px`);
if (bottomValue !== undefined) {
contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
}
})
.fromTo('transform', 'scale(0.8)', 'scale(1)');

View File

@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Popover - Safe Area</title>
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
/**
* Simulate safe-area insets for testing.
* These values represent typical Android edge-to-edge safe areas.
*/
:root {
--ion-safe-area-top: 44px;
--ion-safe-area-bottom: 34px;
--ion-safe-area-left: 0px;
--ion-safe-area-right: 0px;
}
/* Visual indicator for safe areas */
.safe-area-indicator {
position: fixed;
background: rgba(255, 0, 0, 0.2);
pointer-events: none;
z-index: 99999;
}
.safe-area-top {
top: 0;
left: 0;
right: 0;
height: var(--ion-safe-area-top);
}
.safe-area-bottom {
bottom: 0;
left: 0;
right: 0;
height: var(--ion-safe-area-bottom);
}
/* Position triggers at different locations */
.bottom-trigger {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
}
.near-bottom-trigger {
position: fixed;
bottom: 200px;
right: 20px;
}
</style>
</head>
<body>
<ion-app>
<!-- Visual indicators for safe areas -->
<div class="safe-area-indicator safe-area-top"></div>
<div class="safe-area-indicator safe-area-bottom"></div>
<div class="ion-page" id="main-page">
<ion-header>
<ion-toolbar>
<ion-title>Popover - Safe Area Positioning</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>Test that popovers are <strong>positioned away from</strong> unsafe areas (shown in red).</p>
<p>The popover should be moved up/down to avoid overlapping the safe-area zones.</p>
<ion-list>
<ion-item>
<ion-label>
<h2>Small Popover (Center)</h2>
<p>Floating popover - positioned in center, no adjustment needed</p>
</ion-label>
<ion-button slot="end" id="small-popover-trigger">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Large Popover</h2>
<p>Tall content that may extend toward bottom safe area</p>
</ion-label>
<ion-button slot="end" id="large-popover-trigger">Present</ion-button>
</ion-item>
</ion-list>
<ion-button class="bottom-trigger" id="bottom-trigger"> Trigger Near Bottom </ion-button>
<ion-button class="near-bottom-trigger" id="near-bottom-trigger"> Near Bottom Right </ion-button>
<!-- Small popover -->
<ion-popover trigger="small-popover-trigger" trigger-action="click">
<ion-content class="ion-padding">
<ion-list>
<ion-item><ion-label>Option 1</ion-label></ion-item>
<ion-item><ion-label>Option 2</ion-label></ion-item>
<ion-item><ion-label>Option 3</ion-label></ion-item>
</ion-list>
</ion-content>
</ion-popover>
<!-- Large popover with many items -->
<ion-popover trigger="large-popover-trigger" trigger-action="click">
<ion-content>
<ion-list id="large-list"></ion-list>
</ion-content>
</ion-popover>
<!-- Popover triggered from near bottom -->
<ion-popover trigger="bottom-trigger" trigger-action="click">
<ion-content>
<ion-list id="bottom-list"></ion-list>
</ion-content>
</ion-popover>
<!-- Popover triggered from near bottom right -->
<ion-popover trigger="near-bottom-trigger" trigger-action="click">
<ion-content>
<ion-list id="near-bottom-list"></ion-list>
</ion-content>
</ion-popover>
</ion-content>
</div>
</ion-app>
<script>
// Generate list items for popovers
function generateItems(listId, count) {
const list = document.getElementById(listId);
if (!list) return;
for (let i = 1; i <= count; i++) {
const item = document.createElement('ion-item');
const label = document.createElement('ion-label');
label.textContent = `Item ${i}`;
item.appendChild(label);
list.appendChild(item);
}
}
generateItems('large-list', 15);
generateItems('bottom-list', 10);
generateItems('near-bottom-list', 8);
// Log positioning info for debugging
document.querySelectorAll('ion-popover').forEach((popover) => {
popover.addEventListener('ionPopoverDidPresent', () => {
const content = popover.shadowRoot.querySelector('.popover-content');
if (content) {
const rect = content.getBoundingClientRect();
const bottomSafeArea =
parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ion-safe-area-bottom')) || 0;
console.log('Popover position:', {
top: rect.top,
bottom: rect.bottom,
windowHeight: window.innerHeight,
bottomSafeArea,
distanceFromBottom: window.innerHeight - rect.bottom,
overlapsBottomSafeArea: rect.bottom > window.innerHeight - bottomSafeArea,
});
}
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,99 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* Safe-area tests verify that popovers are correctly positioned
* to avoid overlapping with safe-area zones (status bars, navigation bars, etc.)
*
* This is especially important for Android API 36+ where edge-to-edge mode
* is enforced and apps can no longer opt out.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('popover: safe-area positioning'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/popover/test/safe-area', config);
});
test('popover pinned to bottom should account for safe-area-bottom in position', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30900',
});
// Use a smaller viewport to force the popover to be constrained
await page.setViewportSize({ width: 375, height: 500 });
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
// Click the trigger near the bottom of the screen
await page.click('#bottom-trigger');
await ionPopoverDidPresent.next();
// Target the specific popover that was presented (the one with trigger="bottom-trigger")
const popover = page.locator('ion-popover[trigger="bottom-trigger"]');
const popoverContent = popover.locator('.popover-content');
// Get the computed bottom style - should include safe-area calc
const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
// The bottom should include the safe-area-bottom CSS variable
// This ensures the popover is positioned above the unsafe area
expect(bottomStyle).toContain('var(--ion-safe-area-bottom');
});
test('floating popover should not have safe-area adjustments', async ({ page }) => {
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#small-popover-trigger');
await ionPopoverDidPresent.next();
// Target the specific popover
const popover = page.locator('ion-popover[trigger="small-popover-trigger"]');
const popoverContent = popover.locator('.popover-content');
// Get the computed top and bottom styles
const topStyle = await popoverContent.evaluate((el) => el.style.top);
const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
// A floating popover in the middle shouldn't have safe-area adjustments
// The top should be a simple calc without safe-area
expect(topStyle).not.toContain('var(--ion-safe-area-top');
// The bottom should not be set for a floating popover
expect(bottomStyle).toBe('');
});
});
});
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('popover: safe-area positioning - md mode'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/popover/test/safe-area', config);
});
test('popover pinned to bottom should account for safe-area-bottom in position', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30900',
});
// Use a smaller viewport to force the popover to be constrained
await page.setViewportSize({ width: 375, height: 500 });
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#bottom-trigger');
await ionPopoverDidPresent.next();
// Target the specific popover
const popover = page.locator('ion-popover[trigger="bottom-trigger"]');
const popoverContent = popover.locator('.popover-content');
// Get the computed bottom style - should include safe-area calc
const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
// The bottom should include the safe-area-bottom CSS variable
expect(bottomStyle).toContain('var(--ion-safe-area-bottom');
});
});
});

View File

@@ -30,6 +30,8 @@ export interface PopoverStyles {
bottom?: number;
originX: string;
originY: string;
checkSafeAreaTop: boolean;
checkSafeAreaBottom: boolean;
checkSafeAreaLeft: boolean;
checkSafeAreaRight: boolean;
arrowTop: number;
@@ -829,6 +831,8 @@ export const calculateWindowAdjustment = (
let bottom;
let originX = contentOriginX;
let originY = contentOriginY;
let checkSafeAreaTop = false;
let checkSafeAreaBottom = false;
let checkSafeAreaLeft = false;
let checkSafeAreaRight = false;
const triggerTop = triggerCoordinates
@@ -874,17 +878,32 @@ export const calculateWindowAdjustment = (
* We chose 12 here so that the popover position looks a bit nicer as
* it is not right up against the edge of the screen.
*/
top = Math.max(12, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));
top = Math.max(bodyPadding, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));
arrowTop = top + contentHeight;
originY = 'bottom';
addPopoverBottomClass = true;
/**
* If the popover is positioned near the top edge, account for safe area.
* This ensures the popover doesn't overlap with status bars or notches.
*/
if (top <= bodyPadding + safeAreaMargin) {
checkSafeAreaTop = true;
top = bodyPadding;
}
/**
* If not enough room for popover to appear
* above trigger, then cut it off.
*/
} else {
bottom = bodyPadding;
/**
* When the popover is pinned to the bottom, account for safe area.
* This ensures the popover doesn't overlap with home indicators
* or navigation bars (e.g., Android API 36+ edge-to-edge).
*/
checkSafeAreaBottom = true;
}
}
@@ -894,6 +913,8 @@ export const calculateWindowAdjustment = (
bottom,
originX,
originY,
checkSafeAreaTop,
checkSafeAreaBottom,
checkSafeAreaLeft,
checkSafeAreaRight,
arrowTop,