diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts
index 02a078a2f71..43f1332f790 100644
--- a/core/src/components/popover/animations/ios.enter.ts
+++ b/core/src/components/popover/animations/ios.enter.ts
@@ -4,6 +4,7 @@ import { getElementRoot } from '@utils/helpers';
import type { Animation } from '../../../interface';
import {
calculateWindowAdjustment,
+ getDocumentZoom,
getArrowDimensions,
getPopoverDimensions,
getPopoverPosition,
@@ -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;
@@ -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,
@@ -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 {
diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts
index 8de9976e86c..f53af02cc6f 100644
--- a/core/src/components/popover/animations/md.enter.ts
+++ b/core/src/components/popover/animations/md.enter.ts
@@ -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;
@@ -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;
@@ -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,
@@ -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,
diff --git a/core/src/components/popover/test/zoom/popover.e2e.ts b/core/src/components/popover/test/zoom/popover.e2e.ts
new file mode 100644
index 00000000000..395c9f98964
--- /dev/null
+++ b/core/src/components/popover/test/zoom/popover.e2e.ts
@@ -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(
+ `
+
+
+
+ Open
+
+ Popover
+
+
+
+ `,
+ 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(
+ `
+
+
+
+ Open
+
+ Popover
+
+
+
+ `,
+ 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);
+ });
+ });
+});
diff --git a/core/src/components/popover/utils.ts b/core/src/components/popover/utils.ts
index 0d11a4dfeef..a5649be7849 100644
--- a/core/src/components/popover/utils.ts
+++ b/core/src/components/popover/utils.ts
@@ -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
@@ -110,13 +137,13 @@ 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 };
};
/**
@@ -124,14 +151,14 @@ export const getArrowDimensions = (arrowEl: HTMLElement | null) => {
* 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 {
@@ -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,
@@ -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;
@@ -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;