diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts
index 02a078a2f71..4273074f6bb 100644
--- a/core/src/components/popover/animations/ios.enter.ts
+++ b/core/src/components/popover/animations/ios.enter.ts
@@ -5,6 +5,7 @@ import type { Animation } from '../../../interface';
import {
calculateWindowAdjustment,
getArrowDimensions,
+ getElementCSSZoom,
getPopoverDimensions,
getPopoverPosition,
getSafeAreaInsets,
@@ -31,16 +32,31 @@ 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 bodyWidth = doc.defaultView.innerWidth;
- const bodyHeight = doc.defaultView.innerHeight;
-
const root = getElementRoot(baseEl);
const contentEl = root.querySelector('.popover-content') as HTMLElement;
const arrowEl = root.querySelector('.popover-arrow') as HTMLElement | null;
+ /**
+ * A CSS `zoom` other than 1 on an ancestor (e.g. the `html` element) causes
+ * geometry APIs like `getBoundingClientRect()` to report zoomed values while
+ * inline `top`/`left`/`--width` styles are interpreted in the unzoomed layout
+ * space. Normalize all rect-derived measurements by this factor so the
+ * popover is positioned and sized correctly.
+ */
+ const zoom = getElementCSSZoom(contentEl);
+
+ /**
+ * `innerWidth`/`innerHeight` are not affected by CSS `zoom`, so they must be
+ * scaled down to the same layout space as the normalized measurements above.
+ * Otherwise the popover would be clamped against a viewport that is larger
+ * than the space actually available to it.
+ */
+ const bodyWidth = doc.defaultView.innerWidth / zoom;
+ const bodyHeight = doc.defaultView.innerHeight / zoom;
+
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,7 +76,8 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
align,
defaultPosition,
trigger,
- ev
+ ev,
+ zoom
);
const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts
index 8de9976e86c..6d37474ceb2 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,
+ getElementCSSZoom,
+ getPopoverDimensions,
+ getPopoverPosition,
+ getSafeAreaInsets,
+} from '../utils';
const POPOVER_MD_BODY_PADDING = 12;
@@ -15,14 +21,29 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const doc = baseEl.ownerDocument as any;
const isRTL = doc.dir === 'rtl';
- const bodyWidth = doc.defaultView.innerWidth;
- const bodyHeight = doc.defaultView.innerHeight;
-
const root = getElementRoot(baseEl);
const contentEl = root.querySelector('.popover-content') as HTMLElement;
+ /**
+ * A CSS `zoom` other than 1 on an ancestor (e.g. the `html` element) causes
+ * geometry APIs like `getBoundingClientRect()` to report zoomed values while
+ * inline `top`/`left`/`--width` styles are interpreted in the unzoomed layout
+ * space. Normalize all rect-derived measurements by this factor so the
+ * popover is positioned and sized correctly.
+ */
+ const zoom = getElementCSSZoom(contentEl);
+
+ /**
+ * `innerWidth`/`innerHeight` are not affected by CSS `zoom`, so they must be
+ * scaled down to the same layout space as the normalized measurements above.
+ * Otherwise the popover would be clamped against a viewport that is larger
+ * than the space actually available to it.
+ */
+ const bodyWidth = doc.defaultView.innerWidth / zoom;
+ const bodyHeight = doc.defaultView.innerHeight / zoom;
+
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,7 +63,8 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
align,
defaultPosition,
trigger,
- ev
+ ev,
+ zoom
);
const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
diff --git a/core/src/components/popover/test/util.spec.ts b/core/src/components/popover/test/util.spec.ts
index a383209f96c..cda094f39c6 100644
--- a/core/src/components/popover/test/util.spec.ts
+++ b/core/src/components/popover/test/util.spec.ts
@@ -1,4 +1,89 @@
-import { isTriggerElement, getIndexOfItem, getNextItem, getPrevItem } from '../utils';
+import {
+ isTriggerElement,
+ getIndexOfItem,
+ getNextItem,
+ getPrevItem,
+ getElementCSSZoom,
+ getPopoverDimensions,
+ getArrowDimensions,
+} from '../utils';
+
+describe('getElementCSSZoom', () => {
+ it('should return 1 when no element is provided', () => {
+ expect(getElementCSSZoom(null)).toEqual(1);
+ });
+
+ it('should use currentCSSZoom when available', () => {
+ const el = document.createElement('div');
+ Object.defineProperty(el, 'currentCSSZoom', { value: 1.5, configurable: true });
+
+ expect(getElementCSSZoom(el)).toEqual(1.5);
+ });
+
+ it('should fall back to the ratio between the client rect and offsetWidth', () => {
+ const el = document.createElement('div');
+ // No currentCSSZoom support in this environment.
+ el.getBoundingClientRect = () => ({ width: 300, height: 0, top: 0, left: 0, bottom: 0, right: 0 } as DOMRect);
+ Object.defineProperty(el, 'offsetWidth', { value: 200, configurable: true });
+
+ expect(getElementCSSZoom(el)).toEqual(1.5);
+ });
+
+ it('should treat sub-pixel rounding in the fallback as no zoom', () => {
+ const el = document.createElement('div');
+ // offsetWidth is rounded to an integer, the bounding rect is not.
+ el.getBoundingClientRect = () => ({ width: 250.4, height: 0, top: 0, left: 0, bottom: 0, right: 0 } as DOMRect);
+ Object.defineProperty(el, 'offsetWidth', { value: 250, configurable: true });
+
+ expect(getElementCSSZoom(el)).toEqual(1);
+ });
+
+ it('should return 1 when the fallback measurements are unavailable', () => {
+ const el = document.createElement('div');
+ el.getBoundingClientRect = () => ({ width: 0, height: 0, top: 0, left: 0, bottom: 0, right: 0 } as DOMRect);
+ Object.defineProperty(el, 'offsetWidth', { value: 0, configurable: true });
+
+ expect(getElementCSSZoom(el)).toEqual(1);
+ });
+});
+
+describe('getPopoverDimensions', () => {
+ it('should normalize the content dimensions by the zoom factor', () => {
+ const contentEl = document.createElement('div');
+ contentEl.getBoundingClientRect = () =>
+ ({ width: 300, height: 450, top: 0, left: 0, bottom: 0, right: 0 } as DOMRect);
+
+ const { contentWidth, contentHeight } = getPopoverDimensions('auto', contentEl, undefined, 1.5);
+
+ expect(contentWidth).toEqual(200);
+ expect(contentHeight).toEqual(300);
+ });
+
+ it('should normalize the trigger width by the zoom factor when size is cover', () => {
+ const contentEl = document.createElement('div');
+ contentEl.getBoundingClientRect = () =>
+ ({ width: 300, height: 450, top: 0, left: 0, bottom: 0, right: 0 } as DOMRect);
+ const triggerEl = document.createElement('div');
+ triggerEl.getBoundingClientRect = () =>
+ ({ width: 150, height: 60, top: 0, left: 0, bottom: 0, right: 0 } as DOMRect);
+
+ const { contentWidth } = getPopoverDimensions('cover', contentEl, triggerEl, 1.5);
+
+ expect(contentWidth).toEqual(100);
+ });
+});
+
+describe('getArrowDimensions', () => {
+ it('should normalize the arrow dimensions by the zoom factor', () => {
+ const arrowEl = document.createElement('div');
+ arrowEl.getBoundingClientRect = () => ({ width: 15, height: 15, top: 0, left: 0, bottom: 0, right: 0 } as DOMRect);
+
+ const { arrowWidth, arrowHeight } = getArrowDimensions(arrowEl, 1.5);
+
+ expect(arrowWidth).toEqual(10);
+ expect(arrowHeight).toEqual(10);
+ });
+});
describe('isTriggerElement', () => {
it('should return true is element is a trigger', () => {
diff --git a/core/src/components/popover/test/zoom/index.html b/core/src/components/popover/test/zoom/index.html
new file mode 100644
index 00000000000..96a7488768d
--- /dev/null
+++ b/core/src/components/popover/test/zoom/index.html
@@ -0,0 +1,76 @@
+
+
+
+
+ Popover - Zoom
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Popover - Zoom
+
+
+
+
+
+
+ Auto
+
+
+
+
+ Cover
+
+
+
+
+ Edge
+
+
+
+
+
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..dc73039e31a
--- /dev/null
+++ b/core/src/components/popover/test/zoom/popover.e2e.ts
@@ -0,0 +1,193 @@
+import { expect } from '@playwright/test';
+import type { E2EPage } from '@utils/test/playwright';
+import { configs, test } from '@utils/test/playwright';
+
+import { openPopover } from '../test.utils';
+
+/**
+ * A CSS `zoom` causes geometry APIs such as `getBoundingClientRect()` and
+ * pointer `clientX`/`clientY` to report values in the zoomed coordinate space,
+ * while the inline `top`/`left`/`--width` styles the popover sets are
+ * interpreted in the unzoomed layout space. The popover needs to account for
+ * this so it stays anchored to its trigger.
+ *
+ * These are functional assertions rather than screenshots because what is being
+ * verified is the popover's geometry relative to its trigger, not its
+ * appearance. Both boxes are read in the same coordinate space, so the
+ * relationship between them holds at any zoom level.
+ */
+
+/**
+ * Maximum difference, in pixels, between two positions still considered
+ * aligned. Generous enough for sub-pixel rounding across browsers, far tighter
+ * than the error a missing zoom adjustment produces (tens of pixels).
+ */
+const TOLERANCE = 2;
+
+const expectAligned = (actual: number, expected: number) => {
+ expect(Math.abs(actual - expected)).toBeLessThanOrEqual(TOLERANCE);
+};
+
+/**
+ * Builds a page with a trigger and a popover, with `zoomStyles` controlling
+ * where in the tree the zoom is applied. The trigger is kept near the top left
+ * so the popover is never pushed onto the screen by the offscreen adjustment,
+ * which would mask a positioning error.
+ */
+const zoomedPage = (zoomStyles: string) => `
+
+
+
+
+ Content
+
+`;
+
+const expectAnchoredToTrigger = async (page: E2EPage) => {
+ const triggerBox = (await page.locator('#trigger').boundingBox())!;
+ const contentBox = (await page.locator('ion-popover').locator('.popover-content').boundingBox())!;
+
+ expectAligned(contentBox.x, triggerBox.x);
+ expectAligned(contentBox.y, triggerBox.y + triggerBox.height);
+};
+
+/**
+ * This behavior does not vary across directions. MD mode is used because it has
+ * no arrow offsetting the content and defaults to `start` alignment, which
+ * makes the expected relationship to the trigger unambiguous.
+ */
+configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
+ test.describe(title('popover: zoom'), () => {
+ test.beforeEach(() => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/30919',
+ });
+ });
+
+ test.describe('zoom on the html element', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/src/components/popover/test/zoom', config);
+ });
+
+ test('should align the popover with its trigger', async ({ page }) => {
+ await openPopover(page, 'auto-trigger');
+
+ const triggerBox = (await page.locator('#auto-trigger').boundingBox())!;
+ const contentBox = (await page.locator('ion-popover.auto-popover').locator('.popover-content').boundingBox())!;
+
+ expectAligned(contentBox.x, triggerBox.x);
+ expectAligned(contentBox.y, triggerBox.y + triggerBox.height);
+ });
+
+ test('should not render the popover offscreen', async ({ page }) => {
+ await openPopover(page, 'edge-trigger');
+
+ const viewport = page.viewportSize()!;
+ const contentBox = (await page.locator('ion-popover.edge-popover').locator('.popover-content').boundingBox())!;
+
+ expect(contentBox.x).toBeGreaterThanOrEqual(0);
+ expect(contentBox.x + contentBox.width).toBeLessThanOrEqual(viewport.width);
+ });
+
+ test('should match the trigger width when size is cover', async ({ page }) => {
+ await openPopover(page, 'cover-trigger');
+
+ const triggerBox = (await page.locator('#cover-trigger').boundingBox())!;
+ const contentBox = (await page.locator('ion-popover.cover-popover').locator('.popover-content').boundingBox())!;
+
+ expectAligned(contentBox.width, triggerBox.width);
+ });
+ });
+
+ /**
+ * The zoom must be read from the popover's own context rather than from
+ * `document.documentElement`, otherwise a zoom applied lower in the tree is
+ * missed entirely.
+ */
+ test.describe('zoom applied at other levels of the tree', () => {
+ test('should align the popover when zoom is on the body', async ({ page }) => {
+ await page.setContent(zoomedPage('body { zoom: 1.5; }'), config);
+ await openPopover(page, 'trigger');
+
+ await expectAnchoredToTrigger(page);
+ });
+
+ test('should align the popover when zoom accumulates across ancestors', async ({ page }) => {
+ await page.setContent(zoomedPage('html { zoom: 1.2; } body { zoom: 1.25; }'), config);
+ await openPopover(page, 'trigger');
+
+ await expectAnchoredToTrigger(page);
+ });
+
+ test('should align the popover when the page is zoomed out', async ({ page }) => {
+ await page.setContent(zoomedPage('html { zoom: 0.8; }'), config);
+ await openPopover(page, 'trigger');
+
+ await expectAnchoredToTrigger(page);
+ });
+ });
+
+ /**
+ * `reference="event"` positions the popover from the pointer coordinates of
+ * the event, which are reported in the zoomed coordinate space too.
+ */
+ test.describe('pointer coordinates', () => {
+ test('should position the popover at the pointer when reference is event', async ({ page }) => {
+ await page.setContent(
+ zoomedPage('html { zoom: 1.5; }').replace('trigger="trigger"', 'trigger="trigger" reference="event"'),
+ config
+ );
+
+ const triggerBox = (await page.locator('#trigger').boundingBox())!;
+ await openPopover(page, 'trigger');
+
+ const contentBox = (await page.locator('ion-popover').locator('.popover-content').boundingBox())!;
+
+ /**
+ * Playwright clicks the centre of the trigger, which is where the
+ * popover should be anchored.
+ */
+ expectAligned(contentBox.x, triggerBox.x + triggerBox.width / 2);
+ expectAligned(contentBox.y, triggerBox.y + triggerBox.height / 2);
+ });
+ });
+ });
+});
+
+/**
+ * The arrow only exists in ios mode.
+ */
+configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
+ test.describe(title('popover: zoom'), () => {
+ test('should centre the arrow on the trigger when a zoom is applied', async ({ page }) => {
+ test.info().annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/30919',
+ });
+
+ await page.setContent(zoomedPage('html { zoom: 1.5; }'), config);
+ await openPopover(page, 'trigger');
+
+ const triggerBox = (await page.locator('#trigger').boundingBox())!;
+ const arrowBox = (await page.locator('ion-popover').locator('.popover-arrow').boundingBox())!;
+
+ expectAligned(arrowBox.x + arrowBox.width / 2, triggerBox.x + triggerBox.width / 2);
+ });
+ });
+});
diff --git a/core/src/components/popover/utils.ts b/core/src/components/popover/utils.ts
index 0d11a4dfeef..8a257c4809d 100644
--- a/core/src/components/popover/utils.ts
+++ b/core/src/components/popover/utils.ts
@@ -105,18 +105,73 @@ export const getSafeAreaInsets = (doc: Document): SafeAreaInsets => {
return insets;
};
+/**
+ * Largest difference from 1 that the `offsetWidth` based zoom detection below
+ * attributes to integer rounding rather than to an actual CSS `zoom`. The
+ * rounding error is at most half a pixel over the width of the popover, which
+ * is well under this threshold for any realistic popover size.
+ */
+const ZOOM_ROUNDING_TOLERANCE = 0.01;
+
+/**
+ * Returns the cumulative CSS `zoom` factor applied to an element.
+ *
+ * When a CSS `zoom` other than 1 is set on an ancestor (e.g. the `html`
+ * element, as recommended by the docs for dynamic font scaling on Chrome for
+ * Android), `getBoundingClientRect()`, `clientX`/`clientY` and other geometry
+ * APIs report values in the *zoomed* (visual) coordinate space, while inline
+ * `top`/`left`/`--width` styles we set are interpreted in the *unzoomed*
+ * (layout) space and re-scaled by the browser. Dividing the rect-derived
+ * values by this factor converts them back to layout space so the popover is
+ * positioned and sized correctly. Returns 1 when no zoom is applied.
+ */
+export const getElementCSSZoom = (el: HTMLElement | null): number => {
+ if (!el) {
+ return 1;
+ }
+
+ /**
+ * `currentCSSZoom` exposes the exact effective zoom of an element
+ * (Chromium 126+). When available we use it directly.
+ */
+ const currentCSSZoom = (el as unknown as { currentCSSZoom?: number }).currentCSSZoom;
+ if (typeof currentCSSZoom === 'number' && currentCSSZoom > 0) {
+ return currentCSSZoom;
+ }
+
+ /**
+ * Fallback for browsers without `currentCSSZoom`: compare the rendered
+ * (zoomed) width from `getBoundingClientRect()` against the layout width
+ * from `offsetWidth`, which is not affected by CSS `zoom`.
+ */
+ const { width } = el.getBoundingClientRect();
+ const { offsetWidth } = el;
+ if (offsetWidth > 0 && width > 0) {
+ const ratio = width / offsetWidth;
+ /**
+ * `offsetWidth` is rounded to an integer while the bounding rect is not,
+ * so the ratio is rarely exactly 1 even when no zoom is applied. Treat
+ * sub-pixel differences as "no zoom" so that unzoomed popovers are not
+ * shifted by the rounding error. A real zoom deviates far more than this.
+ */
+ return Math.abs(ratio - 1) < ZOOM_ROUNDING_TOLERANCE ? 1 : ratio;
+ }
+
+ return 1;
+};
+
/**
* Returns the dimensions of the popover
* 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 +179,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 +581,8 @@ export const getPopoverPosition = (
align: PositionAlign,
defaultPosition: PopoverPosition,
triggerEl?: HTMLElement,
- event?: MouseEvent | CustomEvent
+ event?: MouseEvent | CustomEvent,
+ zoom = 1
): PopoverPosition => {
let referenceCoordinates = {
top: 0,
@@ -549,8 +605,8 @@ export const getPopoverPosition = (
const mouseEv = event as MouseEvent;
referenceCoordinates = {
- top: mouseEv.clientY,
- left: mouseEv.clientX,
+ top: mouseEv.clientY / zoom,
+ left: mouseEv.clientX / zoom,
width: 1,
height: 1,
};
@@ -585,10 +641,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;