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
29 changes: 23 additions & 6 deletions core/src/components/popover/animations/ios.enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Animation } from '../../../interface';
import {
calculateWindowAdjustment,
getArrowDimensions,
getElementCSSZoom,
getPopoverDimensions,
getPopoverPosition,
getSafeAreaInsets,
Expand All @@ -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,
Expand All @@ -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;
Expand Down
34 changes: 28 additions & 6 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,
getElementCSSZoom,
getPopoverDimensions,
getPopoverPosition,
getSafeAreaInsets,
} from '../utils';

const POPOVER_MD_BODY_PADDING = 12;

Expand All @@ -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,
Expand All @@ -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;
Expand Down
87 changes: 86 additions & 1 deletion core/src/components/popover/test/util.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
76 changes: 76 additions & 0 deletions core/src/components/popover/test/zoom/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Popover - Zoom</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>

<style>
/*
* Applying a CSS zoom to the html element is the approach recommended by
* the Ionic documentation for dynamic font scaling on Chrome for Android.
* https://github.com/ionic-team/ionic-framework/issues/30919
*/
html {
zoom: 1.5;
}

ion-content button.trigger {
display: block;

width: 100px;

margin-bottom: 40px;
padding: 8px;
}

ion-popover {
--width: 120px;
}

/*
* Positioned so that the popover would extend past the right edge of the
* zoomed layout viewport unless it is adjusted back onto the screen.
*/
ion-content button.edge {
width: 60px;

margin-left: 164px;
}
</style>
</head>

<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Popover - Zoom</ion-title>
</ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
<button id="auto-trigger" class="trigger">Auto</button>
<ion-popover trigger="auto-trigger" class="auto-popover">
<ion-content class="ion-padding">Auto</ion-content>
</ion-popover>

<button id="cover-trigger" class="trigger">Cover</button>
<ion-popover trigger="cover-trigger" class="cover-popover" size="cover">
<ion-content class="ion-padding">Cover</ion-content>
</ion-popover>

<button id="edge-trigger" class="trigger edge">Edge</button>
<ion-popover trigger="edge-trigger" class="edge-popover">
<ion-content class="ion-padding">Edge</ion-content>
</ion-popover>
</ion-content>
</ion-app>
</body>
</html>
Loading