Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(popover): adjust position to account for approximate safe area #29068

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 3 additions & 28 deletions core/src/components/popover/animations/ios.enter.ts
Expand Up @@ -53,20 +53,8 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
);

const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
const margin = size === 'cover' ? 0 : 25;

const {
originX,
originY,
top,
left,
bottom,
checkSafeAreaLeft,
checkSafeAreaRight,
arrowTop,
arrowLeft,
addPopoverBottomClass,
} = calculateWindowAdjustment(

const { originX, originY, top, left, bottom, arrowTop, arrowLeft, addPopoverBottomClass } = calculateWindowAdjustment(
side,
results.top,
results.left,
Expand All @@ -75,7 +63,6 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
bodyHeight,
contentWidth,
contentHeight,
margin,
results.originX,
results.originY,
results.referenceCoordinates,
Expand Down Expand Up @@ -122,20 +109,8 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
contentEl.style.setProperty('bottom', `${bottom}px`);
}

const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';

let leftValue = `${left}px`;

if (checkSafeAreaLeft) {
leftValue = `${left}px${safeAreaLeft}`;
}
if (checkSafeAreaRight) {
leftValue = `${left}px${safeAreaRight}`;
}
Comment on lines -125 to -135
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While removing these looks like a step backwards at a glance, they weren't actually working as intended because the checkSafeAreaLeft/Right variables were based on the hardcoded 0/25px guess at the margins. I've removed them to ensure the MD and iOS safe area behavior lines up as closely as possible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I understand: Since we are approximating the safe area bounds we no longer need these checks since it's already handled elsewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right; the adjustment is all done in calculateWindowAdjustment now.


contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
contentEl.style.setProperty('left', `calc(${left}px + var(--offset-x, 0))`);
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);

if (arrowEl !== null) {
Expand Down
1 change: 0 additions & 1 deletion core/src/components/popover/animations/md.enter.ts
Expand Up @@ -56,7 +56,6 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
bodyHeight,
contentWidth,
contentHeight,
0,
results.originX,
results.originY,
results.referenceCoordinates
Expand Down
37 changes: 36 additions & 1 deletion core/src/components/popover/test/adjustment/index.html
Expand Up @@ -16,18 +16,43 @@
import { popoverController } from '../../../../dist/ionic/index.esm.js';
window.popoverController = popoverController;
</script>

<style>
.safe-area-cover {
background-color: white;
position: absolute;

top: 5%;
bottom: 5%;
left: 0;
right: 0;

@media (orientation: landscape) {
left: 5%;
right: 5%;
}
}
</style>
</head>
<body>
<ion-app>
<ion-content>
<p style="text-align: center">Click everywhere to open the popover.</p>
<div style="text-align: center">
<p>Click everywhere to open the popover.</p>
<ion-checkbox id="safe-area-cb">Show Safe Area Approximation</ion-checkbox>
</div>

<div class="safe-area-cover"></div>
</ion-content>
</ion-app>

<script>
document.querySelector('ion-content').addEventListener('click', handleButtonClick);
document.querySelector('#safe-area-cb').addEventListener('ionChange', toggleSafeArea);

async function handleButtonClick(ev) {
if (ev.target.tagName === 'ION-CHECKBOX') return;

const popover = await popoverController.create({
component: 'popover-example-page',
event: ev,
Expand All @@ -37,6 +62,16 @@
popover.present();
}

function toggleSafeArea(ev) {
const content = document.querySelector('ion-content');

if (ev.detail.checked) {
content.style.setProperty('--background', 'lightblue');
} else {
content.style.removeProperty('--background');
}
}

customElements.define(
'popover-example-page',
class PopoverContent extends HTMLElement {
Expand Down
42 changes: 41 additions & 1 deletion core/src/components/popover/test/adjustment/popover.e2e.ts
@@ -1,5 +1,5 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import { configs, test, Viewports } from '@utils/test/playwright';

/**
* This behavior does not vary across modes/directions.
Expand Down Expand Up @@ -33,5 +33,45 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>

expect(box.y > 0).toBe(true);
});

test('should account for vertical safe area approximation in portrait mode', async ({ page }) => {
await page.goto('/src/components/popover/test/adjustment', config);
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');

await page.mouse.click(0, 0);
await ionPopoverDidPresent.next();

const popoverContent = page.locator('ion-popover .popover-content');
const box = (await popoverContent.boundingBox())!;

/**
* The safe area approximation should move the y position by 5%
* of the screen height. We use 10px as the threshold to give
* wiggle room and help prevent flakiness.
*/
expect(box.x < 10).toBe(true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should be screenshot tests according to our best practices guide.

(same as below test)

expect(box.y > 10).toBe(true);
});

test('should account for vertical and horizontal safe area approximation in landscape mode', async ({ page }) => {
await page.goto('/src/components/popover/test/adjustment', config);
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');

await page.setViewportSize(Viewports.tablet.landscape);

await page.mouse.click(0, 0);
await ionPopoverDidPresent.next();

const popoverContent = page.locator('ion-popover .popover-content');
const box = (await popoverContent.boundingBox())!;

/**
* The safe area approximation should move the y position by 5%
* of the screen height. We use 10px as the threshold to give
* wiggle room and help prevent flakiness.
*/
expect(box.x > 10).toBe(true);
expect(box.y > 10).toBe(true);
});
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't look correct. In the old screenshot the arrow is aligned with the popover body for the top-most popover. That's no longer true in the new screenshot.

Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 52 additions & 6 deletions core/src/components/popover/utils.ts
@@ -1,3 +1,4 @@
import { win } from '@utils/browser';
import { getElementRoot, raf } from '@utils/helpers';

import type { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from './popover-interface';
Expand Down Expand Up @@ -814,7 +815,6 @@ export const calculateWindowAdjustment = (
bodyHeight: number,
contentWidth: number,
contentHeight: number,
safeAreaMargin: number,
contentOriginX: string,
contentOriginY: string,
triggerCoordinates?: ReferenceCoordinates,
Expand All @@ -837,32 +837,72 @@ export const calculateWindowAdjustment = (
const triggerHeight = triggerCoordinates ? triggerCoordinates.height : 0;
let addPopoverBottomClass = false;

/**
* Approximate the safe area margins. Getting exact values would necessitate
* using window.getComputedStyle(), which is very expensive, so we use "close
* enough" values for now.
*
* 5% is derived from the iPhone 14 top safe area margin (47pt / 844 pt ~= 0.05).
* Source: https://useyourloaf.com/blog/iphone-14-screen-sizes/
*
* TODO(FW-5982): Investigate a more robust solution that uses the actual
* safe area margins through alternate means.
*/
let horizontalSafeAreaApprox = 0;
let verticalSafeAreaApprox = 0;
if (win?.matchMedia !== undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

matchMedia should always be defined in the supported browsers. Are there other scenarios where this is not defined?

verticalSafeAreaApprox = win.innerHeight * 0.05;

/**
* We only want to check horizontal safe area on landscape.
* Most devices do not have horizontal safe area margins in
* portrait mode, so enforcing it would lead to popovers
* being misaligned with the trigger when we don't want them
* to move.
*/
if (win.matchMedia('(orientation: landscape)').matches) {
horizontalSafeAreaApprox = win.innerWidth * 0.05;
}
amandaejohnston marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Adjust popover so it does not
* go off the left of the screen.
*/
if (left < bodyPadding + safeAreaMargin) {
left = bodyPadding;
if (left < bodyPadding + horizontalSafeAreaApprox) {
left = bodyPadding + horizontalSafeAreaApprox;
checkSafeAreaLeft = true;
originX = 'left';
/**
* 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 + horizontalSafeAreaApprox > bodyWidth) {
checkSafeAreaRight = true;
left = bodyWidth - contentWidth - bodyPadding;
left = bodyWidth - contentWidth - bodyPadding - horizontalSafeAreaApprox;
originX = 'right';
}

/**
* Ensure the popover doesn't sit above the safe area approxmation.
* If popover is on the left or right of the trigger, we should not
* adjust top margins.
*/
if (side === 'top' || side === 'bottom') {
top = Math.max(top, verticalSafeAreaApprox + bodyPadding);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we write a test for this?

}

/**
* Adjust popover so it does not
* go off the top of the screen.
* 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 + triggerHeight + contentHeight + verticalSafeAreaApprox > bodyHeight &&
(side === 'top' || side === 'bottom')
) {
if (triggerTop - contentHeight > 0) {
/**
* While we strive to align the popover with the trigger
Expand All @@ -875,6 +915,12 @@ export const calculateWindowAdjustment = (
* it is not right up against the edge of the screen.
*/
top = Math.max(12, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));

/**
* Ensure the popover doesn't sit below the safe area approxmation.
*/
top = Math.min(top, bodyHeight - verticalSafeAreaApprox - contentHeight - bodyPadding);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we write a test for this?


arrowTop = top + contentHeight;
originY = 'bottom';
addPopoverBottomClass = true;
Expand Down