Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions core/src/components/popover/animations/ios.enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getElementRoot } from '@utils/helpers';
import type { Animation } from '../../../interface';
import {
calculateWindowAdjustment,
getDocumentZoom,
getArrowDimensions,
getPopoverDimensions,
getPopoverPosition,
Expand Down Expand Up @@ -31,6 +32,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const { event: ev, size, trigger, reference, side, align } = opts;
const doc = baseEl.ownerDocument as any;
const isRTL = doc.dir === 'rtl';
const zoom = getDocumentZoom(doc as Document);
const bodyWidth = doc.defaultView.innerWidth;
const bodyHeight = doc.defaultView.innerHeight;

Expand All @@ -39,8 +41,8 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const arrowEl = root.querySelector('.popover-arrow') as HTMLElement | null;

const referenceSizeEl = trigger || ev?.detail?.ionShadowTarget || ev?.target;
const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl);
const { arrowWidth, arrowHeight } = getArrowDimensions(arrowEl);
const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl, zoom);
const { arrowWidth, arrowHeight } = getArrowDimensions(arrowEl, zoom);

const defaultPosition = {
top: bodyHeight / 2 - contentHeight / 2,
Expand All @@ -60,19 +62,26 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
align,
defaultPosition,
trigger,
ev
ev,
zoom
);

const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
const rawSafeArea = getSafeAreaInsets(doc as Document);
const normalizedSafeArea = {
top: rawSafeArea.top / zoom,
bottom: rawSafeArea.bottom / zoom,
left: rawSafeArea.left / zoom,
right: rawSafeArea.right / zoom,
};
const safeArea =
size === 'cover'
? { top: 0, bottom: 0, left: 0, right: 0 }
: {
top: Math.max(rawSafeArea.top, POPOVER_IOS_MIN_EDGE_MARGIN),
bottom: Math.max(rawSafeArea.bottom, POPOVER_IOS_MIN_EDGE_MARGIN),
left: Math.max(rawSafeArea.left, POPOVER_IOS_MIN_EDGE_MARGIN),
right: Math.max(rawSafeArea.right, POPOVER_IOS_MIN_EDGE_MARGIN),
top: Math.max(normalizedSafeArea.top, POPOVER_IOS_MIN_EDGE_MARGIN),
bottom: Math.max(normalizedSafeArea.bottom, POPOVER_IOS_MIN_EDGE_MARGIN),
left: Math.max(normalizedSafeArea.left, POPOVER_IOS_MIN_EDGE_MARGIN),
right: Math.max(normalizedSafeArea.right, POPOVER_IOS_MIN_EDGE_MARGIN),
};

const {
Expand Down
25 changes: 21 additions & 4 deletions core/src/components/popover/animations/md.enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { createAnimation } from '@utils/animation/animation';
import { getElementRoot } from '@utils/helpers';

import type { Animation } from '../../../interface';
import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition, getSafeAreaInsets } from '../utils';
import {
calculateWindowAdjustment,
getDocumentZoom,
getPopoverDimensions,
getPopoverPosition,
getSafeAreaInsets,
} from '../utils';

const POPOVER_MD_BODY_PADDING = 12;

Expand All @@ -14,6 +20,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const { event: ev, size, trigger, reference, side, align } = opts;
const doc = baseEl.ownerDocument as any;
const isRTL = doc.dir === 'rtl';
const zoom = getDocumentZoom(doc as Document);

const bodyWidth = doc.defaultView.innerWidth;
const bodyHeight = doc.defaultView.innerHeight;
Expand All @@ -22,7 +29,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const contentEl = root.querySelector('.popover-content') as HTMLElement;

const referenceSizeEl = trigger || ev?.detail?.ionShadowTarget || ev?.target;
const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl);
const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl, zoom);

const defaultPosition = {
top: bodyHeight / 2 - contentHeight / 2,
Expand All @@ -42,13 +49,23 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
align,
defaultPosition,
trigger,
ev
ev,
zoom
);

const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
// MD mode now applies safe-area insets (previously passed 0, ignoring all safe areas).
// This is needed for Android edge-to-edge (API 36+) where system bars overlap content.
const safeArea = size === 'cover' ? { top: 0, bottom: 0, left: 0, right: 0 } : getSafeAreaInsets(doc as Document);
const rawSafeArea = getSafeAreaInsets(doc as Document);
const safeArea =
size === 'cover'
? { top: 0, bottom: 0, left: 0, right: 0 }
: {
top: rawSafeArea.top / zoom,
bottom: rawSafeArea.bottom / zoom,
left: rawSafeArea.left / zoom,
right: rawSafeArea.right / zoom,
};

const {
originX,
Expand Down
98 changes: 98 additions & 0 deletions core/src/components/popover/test/zoom/popover.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';

configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('popover: html zoom'), () => {
test.beforeEach(({ skip }) => {
/**
* `zoom` is non-standard CSS and is not supported in Firefox.
*/
skip.browser('firefox', 'CSS zoom is not supported in Firefox');
});

test('should position popover correctly when html is zoomed', async ({ page }) => {
await page.setContent(
`
<style>
html {
zoom: 1.5;
}
</style>
<ion-app>
<ion-content class="ion-padding">
<ion-button id="trigger">Open</ion-button>
<ion-popover trigger="trigger" side="bottom" alignment="start">
<ion-content class="ion-padding">Popover</ion-content>
</ion-popover>
</ion-content>
</ion-app>
`,
config
);

const trigger = page.locator('#trigger');
await trigger.click();

const popover = page.locator('ion-popover');
const content = popover.locator('.popover-content');

await expect(content).toBeVisible();
await content.waitFor({ state: 'visible' });

const triggerBox = await trigger.boundingBox();
const contentBox = await content.boundingBox();

expect(triggerBox).not.toBeNull();
expect(contentBox).not.toBeNull();

if (!triggerBox || !contentBox) {
return;
}

expect(Math.abs(contentBox.x - triggerBox.x)).toBeLessThan(2);
expect(Math.abs(contentBox.y - (triggerBox.y + triggerBox.height))).toBeLessThan(2);
});

test('should size cover popover correctly when html is zoomed', async ({ page }) => {
await page.setContent(
`
<style>
html {
zoom: 1.5;
}
</style>
<ion-app>
<ion-content class="ion-padding">
<ion-button id="trigger">Open</ion-button>
<ion-popover trigger="trigger" side="bottom" alignment="start" size="cover">
<ion-content class="ion-padding">Popover</ion-content>
</ion-popover>
</ion-content>
</ion-app>
`,
config
);

const trigger = page.locator('#trigger');
await trigger.click();

const popover = page.locator('ion-popover');
const content = popover.locator('.popover-content');

await expect(content).toBeVisible();
await page.waitForTimeout(350);

const triggerBox = await trigger.boundingBox();
const contentBox = await content.boundingBox();

expect(triggerBox).not.toBeNull();
expect(contentBox).not.toBeNull();

if (!triggerBox || !contentBox) {
return;
}

expect(Math.abs(contentBox.width - triggerBox.width)).toBeLessThan(2);
});
});
});
58 changes: 43 additions & 15 deletions core/src/components/popover/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,33 @@ export interface SafeAreaInsets {
right: number;
}

/**
* `zoom` is non-standard CSS, but it is commonly used in web apps.
* When applied to the `html` element, `getBoundingClientRect()`
* and mouse event coordinates are scaled while `innerWidth/innerHeight`
* remain in the unscaled coordinate space.
*
* To avoid overlays being positioned/sized incorrectly, we normalize
* DOMRect/event values to the same coordinate space as `innerWidth`.
*/
export const getDocumentZoom = (doc: Document): number => {
const win = doc.defaultView;
if (!win) {
return 1;
}

const computedZoom = parseFloat((win.getComputedStyle(doc.documentElement) as any).zoom);
if (Number.isFinite(computedZoom) && computedZoom > 0) {
return computedZoom;
}

const rectWidth = doc.documentElement.getBoundingClientRect().width;
const innerWidth = win.innerWidth;
const zoom = rectWidth > 0 && innerWidth > 0 ? rectWidth / innerWidth : 1;

return Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
};

/**
* Shared per-frame cache for safe-area insets. Avoids creating a temporary
* DOM element and forcing a synchronous reflow on every call within the same
Expand Down Expand Up @@ -110,28 +137,28 @@ export const getSafeAreaInsets = (doc: Document): SafeAreaInsets => {
* arrow on `ios` mode. If arrow is disabled
* returns (0, 0).
*/
export const getArrowDimensions = (arrowEl: HTMLElement | null) => {
export const getArrowDimensions = (arrowEl: HTMLElement | null, zoom = 1) => {
if (!arrowEl) {
return { arrowWidth: 0, arrowHeight: 0 };
}
const { width, height } = arrowEl.getBoundingClientRect();

return { arrowWidth: width, arrowHeight: height };
return { arrowWidth: width / zoom, arrowHeight: height / zoom };
};

/**
* Returns the recommended dimensions of the popover
* that takes into account whether or not the width
* should match the trigger width.
*/
export const getPopoverDimensions = (size: PopoverSize, contentEl: HTMLElement, triggerEl?: HTMLElement) => {
export const getPopoverDimensions = (size: PopoverSize, contentEl: HTMLElement, triggerEl?: HTMLElement, zoom = 1) => {
const contentDimentions = contentEl.getBoundingClientRect();
const contentHeight = contentDimentions.height;
let contentWidth = contentDimentions.width;
const contentHeight = contentDimentions.height / zoom;
let contentWidth = contentDimentions.width / zoom;

if (size === 'cover' && triggerEl) {
const triggerDimensions = triggerEl.getBoundingClientRect();
contentWidth = triggerDimensions.width;
contentWidth = triggerDimensions.width / zoom;
}

return {
Expand Down Expand Up @@ -526,7 +553,8 @@ export const getPopoverPosition = (
align: PositionAlign,
defaultPosition: PopoverPosition,
triggerEl?: HTMLElement,
event?: MouseEvent | CustomEvent
event?: MouseEvent | CustomEvent,
zoom = 1
): PopoverPosition => {
let referenceCoordinates = {
top: 0,
Expand All @@ -549,10 +577,10 @@ export const getPopoverPosition = (
const mouseEv = event as MouseEvent;

referenceCoordinates = {
top: mouseEv.clientY,
left: mouseEv.clientX,
width: 1,
height: 1,
top: mouseEv.clientY / zoom,
left: mouseEv.clientX / zoom,
width: 1 / zoom,
height: 1 / zoom,
};

break;
Expand Down Expand Up @@ -585,10 +613,10 @@ export const getPopoverPosition = (
}
const triggerBoundingBox = actualTriggerEl.getBoundingClientRect();
referenceCoordinates = {
top: triggerBoundingBox.top,
left: triggerBoundingBox.left,
width: triggerBoundingBox.width,
height: triggerBoundingBox.height,
top: triggerBoundingBox.top / zoom,
left: triggerBoundingBox.left / zoom,
width: triggerBoundingBox.width / zoom,
height: triggerBoundingBox.height / zoom,
};

break;
Expand Down