diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 397daaa600..4cbb88d318 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -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(); diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts index aa4e056814..22e8da58d4 100644 --- a/core/src/components/popover/animations/ios.enter.ts +++ b/core/src/components/popover/animations/ios.enter.ts @@ -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}`); diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts index e25f745cec..31ec53ec07 100644 --- a/core/src/components/popover/animations/md.enter.ts +++ b/core/src/components/popover/animations/md.enter.ts @@ -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)'); diff --git a/core/src/components/popover/test/safe-area/index.html b/core/src/components/popover/test/safe-area/index.html new file mode 100644 index 0000000000..306ac0ed04 --- /dev/null +++ b/core/src/components/popover/test/safe-area/index.html @@ -0,0 +1,179 @@ + + + + + Popover - Safe Area + + + + + + + + + + + + +
+
+ +
+ + + Popover - Safe Area Positioning + + + + +

Test that popovers are positioned away from unsafe areas (shown in red).

+

The popover should be moved up/down to avoid overlapping the safe-area zones.

+ + + + +

Small Popover (Center)

+

Floating popover - positioned in center, no adjustment needed

+
+ Present +
+ + + +

Large Popover

+

Tall content that may extend toward bottom safe area

+
+ Present +
+
+ + Trigger Near Bottom + + Near Bottom Right + + + + + + Option 1 + Option 2 + Option 3 + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + 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 0000000000..b8d4272f87 --- /dev/null +++ b/core/src/components/popover/test/safe-area/popover.e2e.ts @@ -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'); + }); + }); +}); diff --git a/core/src/components/popover/utils.ts b/core/src/components/popover/utils.ts index 794ebb2088..5b960aaecb 100644 --- a/core/src/components/popover/utils.ts +++ b/core/src/components/popover/utils.ts @@ -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,