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
72 changes: 71 additions & 1 deletion core/src/components/content/content.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Build, Component, Element, Event, Host, Listen, Method, Prop, forceUpdate, h, readTask } from '@stencil/core';
import {
Build,
Component,
Element,
Event,
Host,
Listen,
Method,
Prop,
Watch,
forceUpdate,
h,
readTask,
} from '@stencil/core';
import { componentOnReady, hasLazyBuild, inheritAriaAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { isPlatform } from '@utils/platform';
Expand Down Expand Up @@ -34,6 +47,7 @@ export class Content implements ComponentInterface {
private backgroundContentEl?: HTMLElement;
private isMainContent = true;
private resizeTimeout: ReturnType<typeof setTimeout> | null = null;
private fullscreenResizeObserver?: ResizeObserver;
private inheritedAttributes: Attributes = {};

private tabsElement: HTMLElement | null = null;
Expand Down Expand Up @@ -77,6 +91,11 @@ export class Content implements ComponentInterface {
*/
@Prop() fullscreen = false;

@Watch('fullscreen')
fullscreenChanged() {
this.setupFullscreenResizeObserver();
}

/**
* Controls where the fixed content is placed relative to the main content
* in the DOM. This can be used to control the order in which fixed elements
Expand Down Expand Up @@ -168,6 +187,14 @@ export class Content implements ComponentInterface {
closestTabs.addEventListener('ionTabBarLoaded', this.tabsLoadCallback);
}
}

// Re-observe on reattach, since componentDidLoad only fires once.
this.setupFullscreenResizeObserver();
}

componentDidLoad() {
// The custom elements build assigns fullscreen after connectedCallback.
this.setupFullscreenResizeObserver();
}

disconnectedCallback() {
Expand All @@ -193,6 +220,49 @@ export class Content implements ComponentInterface {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = null;
}

this.destroyFullscreenResizeObserver();
}

/**
* Header and footer sizes can change after load without a window resize
* firing, so the `resize` listener alone misses those changes.
*
* Popover content is excluded because `contain: none` lets its offsets drive
* the host's own height, which would feed back into the observer.
*/
private setupFullscreenResizeObserver() {
if (!Build.isBrowser || typeof ResizeObserver === 'undefined') {
return;
}

if (!this.fullscreen || hostContext('ion-popover', this.el)) {
this.destroyFullscreenResizeObserver();
return;
}

if (this.fullscreenResizeObserver !== undefined) {
return;
}

this.fullscreenResizeObserver = new ResizeObserver(() => {
// A hidden page reports a 0x0 box, which would zero the offsets. Same
// reasoning as the guard in onResize, minus its debounce so the
// correction lands in the next frame instead of 100ms later.
if (this.el.offsetParent === null) {
return;
}

this.resize();
});
this.fullscreenResizeObserver.observe(this.el);
}

private destroyFullscreenResizeObserver() {
if (this.fullscreenResizeObserver !== undefined) {
this.fullscreenResizeObserver.disconnect();
this.fullscreenResizeObserver = undefined;
}
}

/**
Expand Down
194 changes: 194 additions & 0 deletions core/src/components/content/test/safe-area/content.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';

/**
* ion-content does not have mode-specific styling
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('content: safe-area'), () => {
test('should keep the scroll region flush with the viewport when the safe area top changes after load', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/31337',
});

await page.setContent(
`
<style>:root { --ion-safe-area-top: 0px; }</style>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Header</ion-title>
</ion-toolbar>
</ion-header>

<ion-content fullscreen>
<p>Content</p>
</ion-content>
</ion-app>
`,
config
);

const scrollRegion = page.locator('ion-content .inner-scroll');
const expectFlushWithTop = () =>
expect(async () => {
expect(Math.abs((await scrollRegion.boundingBox())!.y)).toBeLessThanOrEqual(1);
}).toPass({ timeout: 5000 });

await expectFlushWithTop();

await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px'));

await expectFlushWithTop();
});

test('should keep the scroll region flush with the viewport when the safe area bottom changes after load', async ({
page,
}, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/31337',
});

await page.setContent(
`
<style>:root { --ion-safe-area-bottom: 0px; }</style>
<ion-app>
<ion-content fullscreen>
<p>Content</p>
</ion-content>

<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
</ion-app>
`,
config
);

const scrollRegion = page.locator('ion-content .inner-scroll');
const viewportHeight = page.viewportSize()!.height;
const expectFlushWithBottom = () =>
expect(async () => {
const box = (await scrollRegion.boundingBox())!;
expect(Math.abs(box.y + box.height - viewportHeight)).toBeLessThanOrEqual(1);
}).toPass({ timeout: 5000 });

await expectFlushWithBottom();

await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-bottom', '24px'));

await expectFlushWithBottom();
});

test('should recompute the offsets when fullscreen is enabled after load', async ({ page }) => {
await page.setContent(
`
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Header</ion-title>
</ion-toolbar>
</ion-header>

<ion-content>
<p>Content</p>
</ion-content>
</ion-app>
`,
config
);

const content = page.locator('ion-content');
const scrollRegion = page.locator('ion-content .inner-scroll');
const expectFlushWithTop = () =>
expect(async () => {
expect(Math.abs((await scrollRegion.boundingBox())!.y)).toBeLessThanOrEqual(1);
}).toPass({ timeout: 5000 });

await content.evaluate((el: HTMLIonContentElement) => (el.fullscreen = true));
await expectFlushWithTop();

// Only the observer created by the fullscreen watcher can catch this.
await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px'));

await expectFlushWithTop();
});

test('should leave the offsets at zero when fullscreen is disabled after load', async ({ page }) => {
await page.setContent(
`
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Header</ion-title>
</ion-toolbar>
</ion-header>

<ion-content fullscreen>
<p>Content</p>
</ion-content>
</ion-app>
`,
config
);

const content = page.locator('ion-content');
const offsetTop = () => content.evaluate((el) => el.style.getPropertyValue('--offset-top'));

await content.evaluate((el: HTMLIonContentElement) => (el.fullscreen = false));

await expect(async () => {
expect(await offsetTop()).toBe('0px');
}).toPass({ timeout: 5000 });

await page.evaluate(() => document.documentElement.style.setProperty('--ion-safe-area-top', '24px'));
await page.waitForTimeout(300);

expect(await offsetTop()).toBe('0px');
});

test('should keep the offsets while the page is hidden', async ({ page }) => {
await page.setContent(
`
<ion-app>
<div class="ion-page" id="page">
<ion-header>
<ion-toolbar>
<ion-title>Header</ion-title>
</ion-toolbar>
</ion-header>

<ion-content fullscreen>
<p>Content</p>
</ion-content>
</div>
</ion-app>
`,
config
);

const content = page.locator('ion-content');
const scrollRegion = page.locator('ion-content .inner-scroll');
const offsetTop = () => content.evaluate((el) => el.style.getPropertyValue('--offset-top'));

await expect(async () => {
expect(Math.abs((await scrollRegion.boundingBox())!.y)).toBeLessThanOrEqual(1);
}).toPass({ timeout: 5000 });

const beforeHiding = await offsetTop();
expect(beforeHiding).toBe(`${Math.round((await page.locator('ion-header').boundingBox())!.height)}px`);

await page.evaluate(() => document.getElementById('page')!.classList.add('ion-page-hidden'));
// Long enough that a missed visibility guard would have committed by now.
await page.waitForTimeout(300);

expect(await offsetTop()).toBe(beforeHiding);
});
});
});
21 changes: 15 additions & 6 deletions core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
applySafeAreaOverrides,
clearSafeAreaOverrides,
getRootSafeAreaTop,
onRootSafeAreaTopChange,
hasCustomModalDimensions,
type ModalSafeAreaContext,
} from './safe-area-utils';
Expand Down Expand Up @@ -110,6 +111,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
private currentViewIsPortrait?: boolean;
private viewTransitionAnimation?: Animation;
private resizeTimeout?: any;
private unsubscribeRootSafeAreaTop?: () => void;

// Mutation observer to watch for parent removal
private parentRemovalObserver?: MutationObserver;
Expand Down Expand Up @@ -1480,17 +1482,21 @@ export class Modal implements ComponentInterface, OverlayInterface {
// Set the internal offset property with the resolved root safe-area-top value
if (context.isSheetModal) {
this.updateSheetOffsetTop();
this.unsubscribeRootSafeAreaTop = onRootSafeAreaTopChange((safeAreaTop) =>
this.updateSheetOffsetTop(safeAreaTop)
);
}
}

/**
* Resolves the current root --ion-safe-area-top value and sets the
* internal --ion-modal-offset-top property on the host element.
* Called on present and on resize (e.g., device rotation changes safe-area).
* Sets the internal --ion-modal-offset-top property on the host element,
* resolving the current root --ion-safe-area-top when no value is given.
* Called on present, on resize (e.g., device rotation changes safe-area),
* and whenever the root safe-area value itself changes.
*/
private updateSheetOffsetTop(): void {
const safeAreaTop = getRootSafeAreaTop();
this.el.style.setProperty('--ion-modal-offset-top', `${safeAreaTop}px`);
private updateSheetOffsetTop(safeAreaTop?: number): void {
const value = safeAreaTop ?? getRootSafeAreaTop();
this.el.style.setProperty('--ion-modal-offset-top', `${value}px`);
}

/**
Expand Down Expand Up @@ -1593,6 +1599,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
private cleanupSafeAreaOverrides(): void {
clearSafeAreaOverrides(this.el);

this.unsubscribeRootSafeAreaTop?.();
this.unsubscribeRootSafeAreaTop = undefined;

// Remove internal sheet offset property
this.el.style.removeProperty('--ion-modal-offset-top');

Expand Down
39 changes: 39 additions & 0 deletions core/src/components/modal/safe-area-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,45 @@ export const getRootSafeAreaTop = (): number => {
return value;
};

/**
* Calls back when the resolved root `--ion-safe-area-top` changes, which no
* event and no window resize covers. The probe's height tracks the variable, so
* a change to it becomes a size change the observer can see.
*/
export const onRootSafeAreaTopChange = (callback: (safeAreaTop: number) => void): (() => void) => {
const doc = win?.document;
if (!doc?.body || typeof ResizeObserver === 'undefined') {
return () => undefined;
}

const probe = doc.createElement('div');
probe.style.cssText =
'position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;width:0;' +
'height:var(--ion-safe-area-top,0px);';
doc.body.appendChild(probe);

/**
* Seeded with the value the caller has already applied, so a change that
* lands before the observer's first delivery still gets reported. Comparing
* against an unset value instead would consume that first delivery and treat
* the new inset as the baseline.
*/
let lastHeight = getRootSafeAreaTop();
const observer = new ResizeObserver((entries) => {
const { height } = entries[0].contentRect;
if (height !== lastHeight) {
lastHeight = height;
callback(height);
}
});
observer.observe(probe);

return () => {
observer.disconnect();
probe.remove();
};
};

/**
* True when the modal host declares BOTH a non-fullscreen `--width` AND a
* non-fullscreen `--height` (i.e. a centered-dialog-like modal that doesn't
Expand Down
Loading
Loading