fix(modal): attempting to fix scenarios where the modal would become fully constrained

This commit is contained in:
ShaneK
2026-02-03 08:47:27 -08:00
parent 7584e617f1
commit 1d232c8202
4 changed files with 69 additions and 36 deletions

View File

@@ -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))`);

View File

@@ -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)');

View File

@@ -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

View File

@@ -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;