diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts index cf1cedbab4..8abca43ca5 100644 --- a/core/src/components/popover/animations/ios.enter.ts +++ b/core/src/components/popover/animations/ios.enter.ts @@ -93,8 +93,37 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => arrowHeight ); + /** + * 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, 0px)'; + const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0px)'; + const safeAreaLeft = ' + var(--ion-safe-area-left, 0px)'; + const safeAreaRight = ' - var(--ion-safe-area-right, 0px)'; + + 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}`; + } + if (checkSafeAreaRight) { + leftValue = `${left}px${safeAreaRight}`; + } + const baseAnimation = createAnimation(); const backdropAnimation = createAnimation(); + const arrowAnimation = createAnimation(); const contentAnimation = createAnimation(); backdropAnimation @@ -109,11 +138,42 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => // The Chromium team stated that this behavior is expected and not a bug. The element animating opacity creates a backdrop root for the backdrop-filter. // To get around this, instead of animating the wrapper, animate both the arrow and content. // https://bugs.chromium.org/p/chromium/issues/detail?id=1148826 - contentAnimation - .addElement(root.querySelector('.popover-arrow')!) - .addElement(root.querySelector('.popover-content')!) - .fromTo('opacity', 0.01, 1); // TODO(FW-4376) Ensure that arrow also blurs when translucent + if (arrowEl !== null) { + arrowAnimation.addElement(arrowEl).fromTo('opacity', 0.01, 1); + } + + contentAnimation + .addElement(contentEl) + .beforeAddWrite(() => { + contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0px))`); + contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0px))`); + contentEl.style.setProperty('transform-origin', `${originY} ${originX}`); + + if (bottomValue !== undefined) { + contentEl.style.setProperty('bottom', `calc(${bottomValue})`); + /** + * When both top and bottom are explicitly constrained (isFullyConstrained), + * we need to explicitly calculate the height to ensure the popover + * fits within the safe area boundaries. + * + * Using CSS calc with 100vh minus top and bottom values ensures the + * popover height respects both safe areas. We also override max-height + * to prevent it from interfering with the calculated height. + */ + if (isFullyConstrained) { + /** + * Wrap topValue and bottomValue in parentheses to ensure correct + * order of operations in the CSS calc. Without parentheses, the + * safe-area additions would have wrong signs. + */ + const heightCalc = `calc(100vh - (${topValue}) - (${bottomValue}) - var(--offset-y, 0px))`; + contentEl.style.setProperty('height', heightCalc); + contentEl.style.setProperty('max-height', heightCalc); + } + } + }) + .fromTo('opacity', 0.01, 1); return baseAnimation .easing('ease') @@ -127,54 +187,6 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => baseEl.classList.add('popover-bottom'); } - /** - * 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}`; - } - if (checkSafeAreaRight) { - leftValue = `${left}px${safeAreaRight}`; - } - - if (bottomValue !== undefined) { - contentEl.style.setProperty('bottom', `calc(${bottomValue})`); - /** - * 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. - */ - if (isFullyConstrained) { - contentEl.style.setProperty('height', 'unset'); - } - } - - 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}`); - if (arrowEl !== null) { const didAdjustBounds = results.top !== top || results.left !== left; /** @@ -184,12 +196,12 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger) && !isFullyConstrained; if (showArrow) { - arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`); - arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0))`); + arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0px))`); + arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0px))`); } else { arrowEl.style.setProperty('display', 'none'); } } }) - .addAnimation([backdropAnimation, contentAnimation]); + .addAnimation([backdropAnimation, arrowAnimation, contentAnimation]); }; diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts index 70db130b17..94fa52ddb5 100644 --- a/core/src/components/popover/animations/md.enter.ts +++ b/core/src/components/popover/animations/md.enter.ts @@ -137,15 +137,22 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => contentEl.style.setProperty('bottom', `calc(${bottomValue})`); /** * 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 need to explicitly calculate the height to ensure the popover + * fits within the safe area boundaries. * - * 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. + * Using CSS calc with 100vh minus top and bottom values ensures the + * popover height respects both safe areas. We also override max-height + * to prevent it from interfering with the calculated height. */ if (isFullyConstrained) { - contentEl.style.setProperty('height', 'unset'); + /** + * Wrap topValue and bottomValue in parentheses to ensure correct + * order of operations in the CSS calc. Without parentheses, the + * safe-area additions would have wrong signs. + */ + const heightCalc = `calc(100vh - (${topValue}) - (${bottomValue}) - var(--offset-y, 0px))`; + contentEl.style.setProperty('height', heightCalc); + contentEl.style.setProperty('max-height', heightCalc); } } }) diff --git a/core/src/components/popover/utils.ts b/core/src/components/popover/utils.ts index 33ef92c29b..b6a2549897 100644 --- a/core/src/components/popover/utils.ts +++ b/core/src/components/popover/utils.ts @@ -927,13 +927,13 @@ export const calculateWindowAdjustment = ( * * 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 + * CSS safe-area value at runtime, we use a conservative threshold that + * accounts for typical safe-area sizes (usually 40-50px). By checking + * against (safeAreaMargin * 2), we ensure that: + * 1. Any popover close to the viewport boundary gets constrained + * 2. The safe-area CSS variables have room to be applied without overflow */ - if (checkSafeAreaTop && top + contentHeight > bodyHeight - safeAreaMargin - bodyPadding) { + if (checkSafeAreaTop && top + contentHeight > bodyHeight - safeAreaMargin * 2 - bodyPadding) { bottom = bodyPadding; checkSafeAreaBottom = true; isFullyConstrained = true; @@ -965,8 +965,13 @@ export const calculateWindowAdjustment = ( /** * 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. + * + * We also set isFullyConstrained so that height: unset is applied, + * allowing the bottom constraint to actually take effect (otherwise + * the explicit height would override the bottom constraint). */ bottom = bodyPadding; + isFullyConstrained = true; } if (top < safeAreaMargin) { checkSafeAreaTop = true;