Popover safe area test. Triggers are positioned near screen edges.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts b/core/src/components/popover/test/safe-area/popover.e2e.ts
new file mode 100644
index 00000000000..58cca8baa7c
--- /dev/null
+++ b/core/src/components/popover/test/safe-area/popover.e2e.ts
@@ -0,0 +1,81 @@
+import { expect } from '@playwright/test';
+import { configs, test } from '@utils/test/playwright';
+
+/**
+ * Safe-area behavior applies to both iOS and MD modes.
+ * Left/right safe areas are tested in landscape viewport.
+ */
+configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
+ test.describe(title('popover: safe-area'), () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/src/components/popover/test/safe-area', config);
+ });
+
+ test('should not overlap the bottom safe area when trigger is near the bottom edge', async ({ page }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/28411',
+ });
+
+ /**
+ * Use a landscape-style viewport with bottom safe area.
+ * The bottom trigger is positioned at the very bottom of the screen.
+ */
+ await page.setViewportSize({
+ width: 414,
+ height: 400,
+ });
+
+ const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
+
+ await page.locator('#bottom-trigger').click();
+ await ionPopoverDidPresent.next();
+
+ await expect(page).toHaveScreenshot(screenshot('popover-safe-area-bottom'));
+ });
+
+ test('should not overlap the right safe area when trigger is near the right edge', async ({ page }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/28411',
+ });
+
+ /**
+ * Use a landscape viewport with left/right safe areas
+ * to simulate a device rotated to landscape with notch on left.
+ */
+ await page.setViewportSize({
+ width: 600,
+ height: 400,
+ });
+
+ const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
+
+ await page.locator('#right-trigger').click();
+ await ionPopoverDidPresent.next();
+
+ await expect(page).toHaveScreenshot(screenshot('popover-safe-area-right'));
+ });
+
+ test('should handle a large popover near the bottom-right corner without overlapping safe areas', async ({
+ page,
+ }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/28411',
+ });
+
+ await page.setViewportSize({
+ width: 600,
+ height: 400,
+ });
+
+ const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
+
+ await page.locator('#large-popover-trigger').click();
+ await ionPopoverDidPresent.next();
+
+ await expect(page).toHaveScreenshot(screenshot('popover-safe-area-large'));
+ });
+ });
+});
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..7380d98ef50
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..fa56c8b3d61
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..10125b226cd
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..574cc7307a1
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..7d936598896
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..26d2d861dc4
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..d8ee77f057b
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..4692d2d3ea6
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..f2721a2c6e8
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..215d75de8ba
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..900782be5d9
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..9d89837628a
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..a04545790e4
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..4a9bcc9aa42
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..14c84c9b58b
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Chrome-linux.png
new file mode 100644
index 00000000000..6e082daf219
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Chrome-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Firefox-linux.png
new file mode 100644
index 00000000000..abbd6c1406f
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Firefox-linux.png differ
diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Safari-linux.png
new file mode 100644
index 00000000000..9a73a941665
Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Safari-linux.png differ
diff --git a/core/src/components/popover/utils.ts b/core/src/components/popover/utils.ts
index 794ebb20884..0d11a4dfeef 100644
--- a/core/src/components/popover/utils.ts
+++ b/core/src/components/popover/utils.ts
@@ -32,11 +32,79 @@ export interface PopoverStyles {
originY: string;
checkSafeAreaLeft: boolean;
checkSafeAreaRight: boolean;
+ checkSafeAreaTop: boolean;
+ checkSafeAreaBottom: boolean;
arrowTop: number;
arrowLeft: number;
addPopoverBottomClass: boolean;
+ hideArrow: boolean;
}
+export interface SafeAreaInsets {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+}
+
+/**
+ * Shared per-frame cache for safe-area insets. Avoids creating a temporary
+ * DOM element and forcing a synchronous reflow on every call within the same
+ * frame (e.g., multiple popovers presenting simultaneously). Invalidated
+ * after the next animation frame so values stay fresh across orientation
+ * changes and viewport resizes.
+ */
+let cachedInsets: SafeAreaInsets | null = null;
+let cacheInvalidationScheduled = false;
+
+/**
+ * Resolves the current --ion-safe-area-* CSS custom property values
+ * to actual pixel numbers. A temporary element is needed because
+ * getComputedStyle on :root returns the declared value of custom
+ * properties (e.g. "env(safe-area-inset-top)") rather than a
+ * resolved number. By assigning them to standard padding properties,
+ * the browser resolves the value.
+ *
+ * Results are cached for the current frame to avoid repeated reflows.
+ */
+export const getSafeAreaInsets = (doc: Document): SafeAreaInsets => {
+ if (cachedInsets !== null) {
+ return cachedInsets;
+ }
+
+ if (doc.body === null) {
+ return { top: 0, bottom: 0, left: 0, right: 0 };
+ }
+
+ const el = doc.createElement('div');
+ el.style.cssText =
+ 'position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;' +
+ 'padding-top:var(--ion-safe-area-top,0px);' +
+ 'padding-bottom:var(--ion-safe-area-bottom,0px);' +
+ 'padding-left:var(--ion-safe-area-left,0px);' +
+ 'padding-right:var(--ion-safe-area-right,0px);';
+ doc.body.appendChild(el);
+ const style = getComputedStyle(el);
+ const insets = {
+ top: parseFloat(style.paddingTop) || 0,
+ bottom: parseFloat(style.paddingBottom) || 0,
+ left: parseFloat(style.paddingLeft) || 0,
+ right: parseFloat(style.paddingRight) || 0,
+ };
+ el.remove();
+
+ cachedInsets = insets;
+ if (!cacheInvalidationScheduled) {
+ cacheInvalidationScheduled = true;
+ raf(() => {
+ cachedInsets = null;
+ cacheInvalidationScheduled = false;
+ });
+ }
+
+ return insets;
+};
+
/**
* Returns the dimensions of the popover
* arrow on `ios` mode. If arrow is disabled
@@ -804,6 +872,8 @@ const calculatePopoverCenterAlign = (
* Adjusts popover positioning coordinates
* such that popover does not appear offscreen
* or overlapping safe area bounds.
+ *
+ * @internal - This is not part of the public API.
*/
export const calculateWindowAdjustment = (
side: PositionSide,
@@ -814,7 +884,7 @@ export const calculateWindowAdjustment = (
bodyHeight: number,
contentWidth: number,
contentHeight: number,
- safeAreaMargin: number,
+ safeArea: SafeAreaInsets,
contentOriginX: string,
contentOriginY: string,
triggerCoordinates?: ReferenceCoordinates,
@@ -831,17 +901,20 @@ export const calculateWindowAdjustment = (
let originY = contentOriginY;
let checkSafeAreaLeft = false;
let checkSafeAreaRight = false;
+ let checkSafeAreaTop = false;
+ let checkSafeAreaBottom = false;
const triggerTop = triggerCoordinates
? triggerCoordinates.top + triggerCoordinates.height
: bodyHeight / 2 - contentHeight / 2;
const triggerHeight = triggerCoordinates ? triggerCoordinates.height : 0;
let addPopoverBottomClass = false;
+ const hideArrow = false;
/**
* Adjust popover so it does not
* go off the left of the screen.
*/
- if (left < bodyPadding + safeAreaMargin) {
+ if (left < bodyPadding + safeArea.left) {
left = bodyPadding;
checkSafeAreaLeft = true;
originX = 'left';
@@ -849,7 +922,7 @@ export const calculateWindowAdjustment = (
* Adjust popover so it does not
* go off the right of the screen.
*/
- } else if (contentWidth + bodyPadding + left + safeAreaMargin > bodyWidth) {
+ } else if (contentWidth + bodyPadding + left + safeArea.right > bodyWidth) {
checkSafeAreaRight = true;
left = bodyWidth - contentWidth - bodyPadding;
originX = 'right';
@@ -857,34 +930,54 @@ export const calculateWindowAdjustment = (
/**
* Adjust popover so it does not
- * go off the top of the screen.
+ * go off the bottom of the screen
+ * or overlap the bottom safe area.
* If popover is on the left or the right of
* the trigger, then we should not adjust top
* margins.
*/
- if (triggerTop + triggerHeight + contentHeight > bodyHeight && (side === 'top' || side === 'bottom')) {
- if (triggerTop - contentHeight > 0) {
+ if (
+ triggerTop + triggerHeight + contentHeight > bodyHeight - safeArea.bottom &&
+ (side === 'top' || side === 'bottom')
+ ) {
+ /**
+ * Calculate where the popover top would be if flipped
+ * above the trigger. Check whether that position clears
+ * the top safe area with room for bodyPadding.
+ */
+ const idealFlipTop = triggerTop - contentHeight - triggerHeight - (arrowHeight - 1);
+
+ if (idealFlipTop >= safeArea.top + bodyPadding) {
/**
- * While we strive to align the popover with the trigger
- * on smaller screens this is not always possible. As a result,
- * we adjust the popover up so that it does not hang
- * off the bottom of the screen. However, we do not want to move
- * the popover up so much that it goes off the top of the screen.
- *
- * 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.
+ * Popover fits above the trigger without overlapping
+ * the top safe area. Use the ideal position directly —
+ * no safe-area CSS vars needed since it clears both edges.
*/
- top = Math.max(12, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));
+ top = idealFlipTop;
arrowTop = top + contentHeight;
originY = 'bottom';
addPopoverBottomClass = true;
-
+ } else {
/**
- * If not enough room for popover to appear
- * above trigger, then cut it off.
+ * Can't flip above the trigger. Constrain the bottom
+ * edge while keeping the top near the trigger. This
+ * creates a scrollable area anchored to the trigger.
*/
- } else {
bottom = bodyPadding;
+ checkSafeAreaBottom = true;
+
+ /**
+ * When the trigger is near the bottom of the screen,
+ * the calculated top may be at or past the bottom
+ * constraint, leaving zero visible height. In that
+ * case, pull top up to the top safe area so the
+ * popover fills the available space between safe areas.
+ */
+ const bottomEdge = bodyHeight - safeArea.bottom - bodyPadding;
+ if (top >= bottomEdge) {
+ top = safeArea.top + bodyPadding;
+ checkSafeAreaTop = true;
+ }
}
}
@@ -896,9 +989,12 @@ export const calculateWindowAdjustment = (
originY,
checkSafeAreaLeft,
checkSafeAreaRight,
+ checkSafeAreaTop,
+ checkSafeAreaBottom,
arrowTop,
arrowLeft,
addPopoverBottomClass,
+ hideArrow,
};
};