Skip to content

Commit fc68c0c

Browse files
fix(popover): correct positioning and sizing when html zoom is applied
1 parent 2be39da commit fc68c0c

File tree

4 files changed

+178
-26
lines changed

4 files changed

+178
-26
lines changed

core/src/components/popover/animations/ios.enter.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getElementRoot } from '@utils/helpers';
44
import type { Animation } from '../../../interface';
55
import {
66
calculateWindowAdjustment,
7+
getDocumentZoom,
78
getArrowDimensions,
89
getPopoverDimensions,
910
getPopoverPosition,
@@ -31,6 +32,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
3132
const { event: ev, size, trigger, reference, side, align } = opts;
3233
const doc = baseEl.ownerDocument as any;
3334
const isRTL = doc.dir === 'rtl';
35+
const zoom = getDocumentZoom(doc as Document);
3436
const bodyWidth = doc.defaultView.innerWidth;
3537
const bodyHeight = doc.defaultView.innerHeight;
3638

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

4143
const referenceSizeEl = trigger || ev?.detail?.ionShadowTarget || ev?.target;
42-
const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl);
43-
const { arrowWidth, arrowHeight } = getArrowDimensions(arrowEl);
44+
const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl, zoom);
45+
const { arrowWidth, arrowHeight } = getArrowDimensions(arrowEl, zoom);
4446

4547
const defaultPosition = {
4648
top: bodyHeight / 2 - contentHeight / 2,
@@ -60,19 +62,26 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
6062
align,
6163
defaultPosition,
6264
trigger,
63-
ev
65+
ev,
66+
zoom
6467
);
6568

6669
const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
6770
const rawSafeArea = getSafeAreaInsets(doc as Document);
71+
const normalizedSafeArea = {
72+
top: rawSafeArea.top / zoom,
73+
bottom: rawSafeArea.bottom / zoom,
74+
left: rawSafeArea.left / zoom,
75+
right: rawSafeArea.right / zoom,
76+
};
6877
const safeArea =
6978
size === 'cover'
7079
? { top: 0, bottom: 0, left: 0, right: 0 }
7180
: {
72-
top: Math.max(rawSafeArea.top, POPOVER_IOS_MIN_EDGE_MARGIN),
73-
bottom: Math.max(rawSafeArea.bottom, POPOVER_IOS_MIN_EDGE_MARGIN),
74-
left: Math.max(rawSafeArea.left, POPOVER_IOS_MIN_EDGE_MARGIN),
75-
right: Math.max(rawSafeArea.right, POPOVER_IOS_MIN_EDGE_MARGIN),
81+
top: Math.max(normalizedSafeArea.top, POPOVER_IOS_MIN_EDGE_MARGIN),
82+
bottom: Math.max(normalizedSafeArea.bottom, POPOVER_IOS_MIN_EDGE_MARGIN),
83+
left: Math.max(normalizedSafeArea.left, POPOVER_IOS_MIN_EDGE_MARGIN),
84+
right: Math.max(normalizedSafeArea.right, POPOVER_IOS_MIN_EDGE_MARGIN),
7685
};
7786

7887
const {

core/src/components/popover/animations/md.enter.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { createAnimation } from '@utils/animation/animation';
22
import { getElementRoot } from '@utils/helpers';
33

44
import type { Animation } from '../../../interface';
5-
import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition, getSafeAreaInsets } from '../utils';
5+
import {
6+
calculateWindowAdjustment,
7+
getDocumentZoom,
8+
getPopoverDimensions,
9+
getPopoverPosition,
10+
getSafeAreaInsets,
11+
} from '../utils';
612

713
const POPOVER_MD_BODY_PADDING = 12;
814

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

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

2431
const referenceSizeEl = trigger || ev?.detail?.ionShadowTarget || ev?.target;
25-
const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl);
32+
const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl, zoom);
2633

2734
const defaultPosition = {
2835
top: bodyHeight / 2 - contentHeight / 2,
@@ -42,13 +49,23 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
4249
align,
4350
defaultPosition,
4451
trigger,
45-
ev
52+
ev,
53+
zoom
4654
);
4755

4856
const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
4957
// MD mode now applies safe-area insets (previously passed 0, ignoring all safe areas).
5058
// This is needed for Android edge-to-edge (API 36+) where system bars overlap content.
51-
const safeArea = size === 'cover' ? { top: 0, bottom: 0, left: 0, right: 0 } : getSafeAreaInsets(doc as Document);
59+
const rawSafeArea = getSafeAreaInsets(doc as Document);
60+
const safeArea =
61+
size === 'cover'
62+
? { top: 0, bottom: 0, left: 0, right: 0 }
63+
: {
64+
top: rawSafeArea.top / zoom,
65+
bottom: rawSafeArea.bottom / zoom,
66+
left: rawSafeArea.left / zoom,
67+
right: rawSafeArea.right / zoom,
68+
};
5269

5370
const {
5471
originX,
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { expect } from '@playwright/test';
2+
import { configs, test } from '@utils/test/playwright';
3+
4+
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
5+
test.describe(title('popover: html zoom'), () => {
6+
test.beforeEach(({ skip }) => {
7+
/**
8+
* `zoom` is non-standard CSS and is not supported in Firefox.
9+
*/
10+
skip.browser('firefox', 'CSS zoom is not supported in Firefox');
11+
});
12+
13+
test('should position popover correctly when html is zoomed', async ({ page }) => {
14+
await page.setContent(
15+
`
16+
<style>
17+
html {
18+
zoom: 1.5;
19+
}
20+
</style>
21+
<ion-app>
22+
<ion-content class="ion-padding">
23+
<ion-button id="trigger">Open</ion-button>
24+
<ion-popover trigger="trigger" side="bottom" alignment="start">
25+
<ion-content class="ion-padding">Popover</ion-content>
26+
</ion-popover>
27+
</ion-content>
28+
</ion-app>
29+
`,
30+
config
31+
);
32+
33+
const trigger = page.locator('#trigger');
34+
await trigger.click();
35+
36+
const popover = page.locator('ion-popover');
37+
const content = popover.locator('.popover-content');
38+
39+
await expect(content).toBeVisible();
40+
await content.waitFor({ state: 'visible' });
41+
42+
const triggerBox = await trigger.boundingBox();
43+
const contentBox = await content.boundingBox();
44+
45+
expect(triggerBox).not.toBeNull();
46+
expect(contentBox).not.toBeNull();
47+
48+
if (!triggerBox || !contentBox) {
49+
return;
50+
}
51+
52+
expect(Math.abs(contentBox.x - triggerBox.x)).toBeLessThan(2);
53+
expect(Math.abs(contentBox.y - (triggerBox.y + triggerBox.height))).toBeLessThan(2);
54+
});
55+
56+
test('should size cover popover correctly when html is zoomed', async ({ page }) => {
57+
await page.setContent(
58+
`
59+
<style>
60+
html {
61+
zoom: 1.5;
62+
}
63+
</style>
64+
<ion-app>
65+
<ion-content class="ion-padding">
66+
<ion-button id="trigger">Open</ion-button>
67+
<ion-popover trigger="trigger" side="bottom" alignment="start" size="cover">
68+
<ion-content class="ion-padding">Popover</ion-content>
69+
</ion-popover>
70+
</ion-content>
71+
</ion-app>
72+
`,
73+
config
74+
);
75+
76+
const trigger = page.locator('#trigger');
77+
await trigger.click();
78+
79+
const popover = page.locator('ion-popover');
80+
const content = popover.locator('.popover-content');
81+
82+
await expect(content).toBeVisible();
83+
await page.waitForTimeout(350);
84+
85+
const triggerBox = await trigger.boundingBox();
86+
const contentBox = await content.boundingBox();
87+
88+
expect(triggerBox).not.toBeNull();
89+
expect(contentBox).not.toBeNull();
90+
91+
if (!triggerBox || !contentBox) {
92+
return;
93+
}
94+
95+
expect(Math.abs(contentBox.width - triggerBox.width)).toBeLessThan(2);
96+
});
97+
});
98+
});

core/src/components/popover/utils.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,33 @@ export interface SafeAreaInsets {
4747
right: number;
4848
}
4949

50+
/**
51+
* `zoom` is non-standard CSS, but it is commonly used in web apps.
52+
* When applied to the `html` element, `getBoundingClientRect()`
53+
* and mouse event coordinates are scaled while `innerWidth/innerHeight`
54+
* remain in the unscaled coordinate space.
55+
*
56+
* To avoid overlays being positioned/sized incorrectly, we normalize
57+
* DOMRect/event values to the same coordinate space as `innerWidth`.
58+
*/
59+
export const getDocumentZoom = (doc: Document): number => {
60+
const win = doc.defaultView;
61+
if (!win) {
62+
return 1;
63+
}
64+
65+
const computedZoom = parseFloat((win.getComputedStyle(doc.documentElement) as any).zoom);
66+
if (Number.isFinite(computedZoom) && computedZoom > 0) {
67+
return computedZoom;
68+
}
69+
70+
const rectWidth = doc.documentElement.getBoundingClientRect().width;
71+
const innerWidth = win.innerWidth;
72+
const zoom = rectWidth > 0 && innerWidth > 0 ? rectWidth / innerWidth : 1;
73+
74+
return Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
75+
};
76+
5077
/**
5178
* Shared per-frame cache for safe-area insets. Avoids creating a temporary
5279
* DOM element and forcing a synchronous reflow on every call within the same
@@ -110,28 +137,28 @@ export const getSafeAreaInsets = (doc: Document): SafeAreaInsets => {
110137
* arrow on `ios` mode. If arrow is disabled
111138
* returns (0, 0).
112139
*/
113-
export const getArrowDimensions = (arrowEl: HTMLElement | null) => {
140+
export const getArrowDimensions = (arrowEl: HTMLElement | null, zoom = 1) => {
114141
if (!arrowEl) {
115142
return { arrowWidth: 0, arrowHeight: 0 };
116143
}
117144
const { width, height } = arrowEl.getBoundingClientRect();
118145

119-
return { arrowWidth: width, arrowHeight: height };
146+
return { arrowWidth: width / zoom, arrowHeight: height / zoom };
120147
};
121148

122149
/**
123150
* Returns the recommended dimensions of the popover
124151
* that takes into account whether or not the width
125152
* should match the trigger width.
126153
*/
127-
export const getPopoverDimensions = (size: PopoverSize, contentEl: HTMLElement, triggerEl?: HTMLElement) => {
154+
export const getPopoverDimensions = (size: PopoverSize, contentEl: HTMLElement, triggerEl?: HTMLElement, zoom = 1) => {
128155
const contentDimentions = contentEl.getBoundingClientRect();
129-
const contentHeight = contentDimentions.height;
130-
let contentWidth = contentDimentions.width;
156+
const contentHeight = contentDimentions.height / zoom;
157+
let contentWidth = contentDimentions.width / zoom;
131158

132159
if (size === 'cover' && triggerEl) {
133160
const triggerDimensions = triggerEl.getBoundingClientRect();
134-
contentWidth = triggerDimensions.width;
161+
contentWidth = triggerDimensions.width / zoom;
135162
}
136163

137164
return {
@@ -526,7 +553,8 @@ export const getPopoverPosition = (
526553
align: PositionAlign,
527554
defaultPosition: PopoverPosition,
528555
triggerEl?: HTMLElement,
529-
event?: MouseEvent | CustomEvent
556+
event?: MouseEvent | CustomEvent,
557+
zoom = 1
530558
): PopoverPosition => {
531559
let referenceCoordinates = {
532560
top: 0,
@@ -549,10 +577,10 @@ export const getPopoverPosition = (
549577
const mouseEv = event as MouseEvent;
550578

551579
referenceCoordinates = {
552-
top: mouseEv.clientY,
553-
left: mouseEv.clientX,
554-
width: 1,
555-
height: 1,
580+
top: mouseEv.clientY / zoom,
581+
left: mouseEv.clientX / zoom,
582+
width: 1 / zoom,
583+
height: 1 / zoom,
556584
};
557585

558586
break;
@@ -585,10 +613,10 @@ export const getPopoverPosition = (
585613
}
586614
const triggerBoundingBox = actualTriggerEl.getBoundingClientRect();
587615
referenceCoordinates = {
588-
top: triggerBoundingBox.top,
589-
left: triggerBoundingBox.left,
590-
width: triggerBoundingBox.width,
591-
height: triggerBoundingBox.height,
616+
top: triggerBoundingBox.top / zoom,
617+
left: triggerBoundingBox.left / zoom,
618+
width: triggerBoundingBox.width / zoom,
619+
height: triggerBoundingBox.height / zoom,
592620
};
593621

594622
break;

0 commit comments

Comments
 (0)