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;