diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts index a67ce0a6e4..cf1cedbab4 100644 --- a/core/src/components/popover/animations/ios.enter.ts +++ b/core/src/components/popover/animations/ios.enter.ts @@ -158,12 +158,17 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => if (bottomValue !== undefined) { contentEl.style.setProperty('bottom', `calc(${bottomValue})`); /** - * When both top and bottom are set, we need to override the - * height: var(--height) style to allow the top/bottom constraint - * to determine the height. Setting height to 'auto' with both - * top and bottom defined would cause bottom to be ignored. + * When both top and bottom are explicitly constrained (isFullyConstrained), + * we need to override the height: var(--height) style to allow the + * top/bottom constraint to determine the height. + * + * We only do this when fully constrained because setting height: unset + * when only bottom is set (without explicit top) would result in an + * incorrectly sized popover. */ - contentEl.style.setProperty('height', 'unset'); + if (isFullyConstrained) { + contentEl.style.setProperty('height', 'unset'); + } } contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0))`); diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts index db8370562e..70db130b17 100644 --- a/core/src/components/popover/animations/md.enter.ts +++ b/core/src/components/popover/animations/md.enter.ts @@ -65,6 +65,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => checkSafeAreaBottom, checkSafeAreaLeft, checkSafeAreaRight, + isFullyConstrained, } = calculateWindowAdjustment( side, results.top, @@ -135,12 +136,17 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => if (bottomValue !== undefined) { contentEl.style.setProperty('bottom', `calc(${bottomValue})`); /** - * When both top and bottom are set, we need to override the - * height: var(--height) style to allow the top/bottom constraint - * to determine the height. Setting height to 'auto' with both - * top and bottom defined would cause bottom to be ignored. + * When both top and bottom are explicitly constrained (isFullyConstrained), + * we need to override the height: var(--height) style to allow the + * top/bottom constraint to determine the height. + * + * We only do this when fully constrained because setting height: unset + * when only bottom is set (without explicit top) would result in an + * incorrectly sized popover. */ - contentEl.style.setProperty('height', 'unset'); + if (isFullyConstrained) { + contentEl.style.setProperty('height', 'unset'); + } } }) .fromTo('transform', 'scale(0.8)', 'scale(1)'); diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts b/core/src/components/popover/test/safe-area/popover.e2e.ts index c4e1bf593b..2e49dc1b75 100644 --- a/core/src/components/popover/test/safe-area/popover.e2e.ts +++ b/core/src/components/popover/test/safe-area/popover.e2e.ts @@ -25,17 +25,25 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config 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 }); + /** + * Use a small viewport to force the popover to be fully constrained. + * The large popover has 15 items (~700px), which will exceed the available + * space in this viewport, causing it to be constrained with both top and + * bottom edges near the safe areas. + * + * A 300px viewport ensures there's not enough space above OR below the + * trigger for the full popover content, triggering the fully constrained path. + */ + await page.setViewportSize({ width: 375, height: 300 }); const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); - // Click the trigger near the bottom of the screen - await page.click('#bottom-trigger'); + // Click the large popover trigger which has enough content to extend toward the bottom + await page.click('#large-popover-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"]'); + // Target the specific popover that was presented + const popover = page.locator('ion-popover[trigger="large-popover-trigger"]'); const popoverContent = popover.locator('.popover-content'); // Get the computed bottom style - should include safe-area calc diff --git a/core/src/components/popover/utils.ts b/core/src/components/popover/utils.ts index f02b4f0d76..33ef92c29b 100644 --- a/core/src/components/popover/utils.ts +++ b/core/src/components/popover/utils.ts @@ -884,7 +884,19 @@ export const calculateWindowAdjustment = ( * margins. */ if (triggerTop + triggerHeight + contentHeight > bodyHeight && (side === 'top' || side === 'bottom')) { - if (triggerTop - contentHeight > 0) { + /** + * Calculate available space above and below, accounting for safe areas. + * This ensures we flip to whichever side has more usable space. + */ + const spaceAbove = (triggerCoordinates?.top ?? triggerTop) - bodyPadding - safeAreaMargin; + const spaceBelow = bodyHeight - triggerTop - triggerHeight - bodyPadding - safeAreaMargin; + + /** + * Flip above if: + * 1. Content fits entirely above the trigger, OR + * 2. There's more usable space above than below (accounting for safe areas) + */ + if (triggerTop - contentHeight > 0 || spaceAbove > spaceBelow) { /** * While we strive to align the popover with the trigger * on smaller screens this is not always possible. As a result, @@ -910,25 +922,27 @@ export const calculateWindowAdjustment = ( } /** - * After flipping above, check if popover still extends into bottom safe area. - * This can happen when the popover is taller than the available space between - * the top safe area and the trigger. In this case, constrain with bottom too. + * After flipping above, check if popover will likely overflow the viewport. + * This can happen when the popover is taller than the available space. * - * We estimate the effective top by adding safeAreaMargin if checkSafeAreaTop - * is true (since CSS will add the actual safe-area-top value). + * When checkSafeAreaTop is true, the CSS will add safe-area-top to the + * top position, pushing the popover down. Since we don't know the exact + * CSS safe-area value, we use a threshold that accounts for likely + * safe-area sizes. This only triggers when: + * 1. We're already applying safe-area-top (checkSafeAreaTop), and + * 2. The popover is close enough to overflowing that any safe-area + * would push it past the viewport */ - const estimatedTop = checkSafeAreaTop ? top + safeAreaMargin : top; - if (estimatedTop + contentHeight > bodyHeight - safeAreaMargin) { + if (checkSafeAreaTop && top + contentHeight > bodyHeight - safeAreaMargin - bodyPadding) { bottom = bodyPadding; checkSafeAreaBottom = true; isFullyConstrained = true; } /** - * If not enough room for popover to appear - * above trigger, constrain to full viewport. - * Pin both top and bottom to maximize visible area - * and let the content scroll within those bounds. + * If not enough room for popover to appear above trigger + * (i.e., content is taller than space above), then constrain + * the popover to fill the entire viewport from top to bottom. */ } else { top = bodyPadding; @@ -940,19 +954,19 @@ export const calculateWindowAdjustment = ( } /** - * Final check: If the popover extends into any safe-area region, - * constrain it to avoid overlapping system UI. - * This handles cases where a side-positioned popover (left/right) - * or a bottom-positioned popover extends into the safe area. + * Check if popover is near edges and needs safe-area adjustments. + * When the popover extends into the safe-area zone, set a bottom constraint + * to push it up and out of the unsafe area. This is essential for + * edge-to-edge displays on Android API 36+ and iOS devices with home indicators. */ const popoverBottom = bottom !== undefined ? bodyHeight - bottom : top + contentHeight; - if (popoverBottom + safeAreaMargin > bodyHeight && bottom === undefined) { + if (popoverBottom > bodyHeight - safeAreaMargin && bottom === undefined) { + checkSafeAreaBottom = true; /** - * Popover extends into bottom safe area but isn't already constrained. - * Set bottom to constrain the popover and apply safe-area adjustment. + * Set a bottom constraint to push the popover up out of the safe-area zone. + * The animation will add the safe-area CSS variable to this value. */ bottom = bodyPadding; - checkSafeAreaBottom = true; } if (top < safeAreaMargin) { checkSafeAreaTop = true;