fix(modal): addressing edge cases, cleaning up

This commit is contained in:
ShaneK
2026-01-08 09:02:51 -08:00
parent 26b6b7bb02
commit a63afa3db6
7 changed files with 491 additions and 65 deletions

View File

@@ -203,8 +203,13 @@ export class Content implements ComponentInterface {
const parent = this.el.parentElement;
if (parent && !this.parentMutationObserver && win !== undefined && 'MutationObserver' in win) {
this.parentMutationObserver = new MutationObserver(() => {
const prevHasHeader = this.hasHeader;
const prevHasFooter = this.hasFooter;
this.updateSiblingDetection();
forceUpdate(this);
// Only trigger re-render if header/footer detection actually changed
if (prevHasHeader !== this.hasHeader || prevHasFooter !== this.hasFooter) {
forceUpdate(this);
}
});
this.parentMutationObserver.observe(parent, { childList: true });
}

View File

@@ -0,0 +1,167 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* Safe-area tests verify that ion-content correctly applies safe-area classes
* based on the presence/absence of sibling ion-header and ion-footer elements.
*
* These tests verify the FW-6830 feature: automatic safe-area handling for content.
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('content: safe-area'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/content/test/safe-area', config);
});
test('content without header should have safe-area-top class', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const content = page.locator('#content-no-header');
await expect(content).toHaveClass(/safe-area-top/);
await expect(content).not.toHaveClass(/safe-area-bottom/);
});
test('content without footer should have safe-area-bottom class', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const content = page.locator('#content-no-footer');
await expect(content).not.toHaveClass(/safe-area-top/);
await expect(content).toHaveClass(/safe-area-bottom/);
});
test('content with both header and footer should not have safe-area classes', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const content = page.locator('#content-with-both');
await expect(content).not.toHaveClass(/safe-area-top/);
await expect(content).not.toHaveClass(/safe-area-bottom/);
});
test('content without header or footer should have both safe-area classes', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const content = page.locator('#content-no-both');
await expect(content).toHaveClass(/safe-area-top/);
await expect(content).toHaveClass(/safe-area-bottom/);
});
test('content with wrapped header should not have safe-area-top class', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const content = page.locator('#content-wrapped-header');
// Wrapped header detection should find the ion-header inside my-header
await expect(content).not.toHaveClass(/safe-area-top/);
});
test('content with wrapped footer should not have safe-area-bottom class', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const content = page.locator('#content-wrapped-footer');
// Wrapped footer detection should find the ion-footer inside my-footer
await expect(content).not.toHaveClass(/safe-area-bottom/);
});
test('nested content should not have safe-area classes', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const nestedContent = page.locator('#content-nested');
// Nested content should not be treated as main content
await expect(nestedContent).not.toHaveClass(/safe-area-top/);
await expect(nestedContent).not.toHaveClass(/safe-area-bottom/);
});
test('outer content should still have safe-area classes', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const outerContent = page.locator('#content-outer');
// Outer content has no sibling header/footer, so it should have safe-area classes
await expect(outerContent).toHaveClass(/safe-area-top/);
await expect(outerContent).toHaveClass(/safe-area-bottom/);
});
test('content inside modal should not have safe-area classes', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
// Open the modal
await page.evaluate(() => {
const modal = document.getElementById('test-modal') as HTMLIonModalElement;
modal.isOpen = true;
});
// Wait for modal to be presented
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await ionModalDidPresent.next();
const modalContent = page.locator('#content-in-modal');
// Content inside modal should not be treated as main content
await expect(modalContent).not.toHaveClass(/safe-area-top/);
await expect(modalContent).not.toHaveClass(/safe-area-bottom/);
});
test('dynamic header addition should update safe-area classes', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const content = page.locator('#content-dynamic');
// Initially should have safe-area-top (no header)
await expect(content).toHaveClass(/safe-area-top/);
// Add header dynamically
await page.click('#add-header-btn');
// Wait for mutation observer to trigger and component to update
// Using expect with timeout instead of waitForTimeout for reliability
await expect(content).not.toHaveClass(/safe-area-top/, { timeout: 1000 });
});
test('dynamic header removal should update safe-area classes', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const content = page.locator('#content-dynamic');
// Add header first
await page.click('#add-header-btn');
await expect(content).not.toHaveClass(/safe-area-top/, { timeout: 1000 });
// Remove header
await page.click('#remove-header-btn');
// Should have safe-area-top again
await expect(content).toHaveClass(/safe-area-top/, { timeout: 1000 });
});
});
});

View File

@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Content - Safe Area</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
/* Simulate safe-area insets for testing */
:root {
--ion-safe-area-top: 44px;
--ion-safe-area-bottom: 34px;
--ion-safe-area-left: 0px;
--ion-safe-area-right: 0px;
}
.test-section {
border: 2px solid #ccc;
margin: 10px;
height: 200px;
position: relative;
}
.test-section .ion-page {
position: relative;
height: 100%;
}
/* Custom wrapper component for testing wrapped header/footer detection */
my-header,
my-footer {
display: contents;
}
</style>
</head>
<body>
<ion-app>
<!-- Test 1: Content without header - should have safe-area-top -->
<div id="test-no-header" class="test-section">
<div class="ion-page">
<ion-content id="content-no-header">
<p>Content without header - should have safe-area-top class</p>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
</div>
</div>
<!-- Test 2: Content without footer - should have safe-area-bottom -->
<div id="test-no-footer" class="test-section">
<div class="ion-page">
<ion-header>
<ion-toolbar>
<ion-title>Header</ion-title>
</ion-toolbar>
</ion-header>
<ion-content id="content-no-footer">
<p>Content without footer - should have safe-area-bottom class</p>
</ion-content>
</div>
</div>
<!-- Test 3: Content with both header and footer - should NOT have safe-area classes -->
<div id="test-with-both" class="test-section">
<div class="ion-page">
<ion-header>
<ion-toolbar>
<ion-title>Header</ion-title>
</ion-toolbar>
</ion-header>
<ion-content id="content-with-both">
<p>Content with both header and footer - should NOT have safe-area classes</p>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
</div>
</div>
<!-- Test 4: Content without both header and footer - should have both safe-area classes -->
<div id="test-no-both" class="test-section">
<div class="ion-page">
<ion-content id="content-no-both">
<p>Content without header or footer - should have both safe-area classes</p>
</ion-content>
</div>
</div>
<!-- Test 5: Content with wrapped header component - should NOT have safe-area-top -->
<div id="test-wrapped-header" class="test-section">
<div class="ion-page">
<my-header>
<ion-header>
<ion-toolbar>
<ion-title>Wrapped Header</ion-title>
</ion-toolbar>
</ion-header>
</my-header>
<ion-content id="content-wrapped-header">
<p>Content with wrapped header - should NOT have safe-area-top class</p>
</ion-content>
</div>
</div>
<!-- Test 6: Content with wrapped footer component - should NOT have safe-area-bottom -->
<div id="test-wrapped-footer" class="test-section">
<div class="ion-page">
<ion-content id="content-wrapped-footer">
<p>Content with wrapped footer - should NOT have safe-area-bottom class</p>
</ion-content>
<my-footer>
<ion-footer>
<ion-toolbar>
<ion-title>Wrapped Footer</ion-title>
</ion-toolbar>
</ion-footer>
</my-footer>
</div>
</div>
<!-- Test 7: Nested content - should NOT have safe-area classes (inner content) -->
<div id="test-nested" class="test-section">
<div class="ion-page">
<ion-content id="content-outer">
<p>Outer content</p>
<div style="height: 100px; border: 1px solid blue">
<ion-content id="content-nested">
<p>Nested content - should NOT have safe-area classes</p>
</ion-content>
</div>
</ion-content>
</div>
</div>
<!-- Test 8: Content inside modal - should NOT have safe-area classes -->
<ion-modal id="test-modal" is-open="false">
<ion-content id="content-in-modal">
<p>Content inside modal - should NOT have safe-area classes</p>
</ion-content>
</ion-modal>
<!-- Test 9: Dynamic header/footer - for testing mutation observer -->
<div id="test-dynamic" class="test-section">
<div class="ion-page" id="dynamic-page">
<ion-content id="content-dynamic">
<p>Content with dynamic header/footer</p>
<button id="add-header-btn" onclick="addHeader()">Add Header</button>
<button id="remove-header-btn" onclick="removeHeader()">Remove Header</button>
</ion-content>
</div>
</div>
<script>
function addHeader() {
const page = document.getElementById('dynamic-page');
const content = document.getElementById('content-dynamic');
if (!page.querySelector('ion-header')) {
const header = document.createElement('ion-header');
header.innerHTML = '<ion-toolbar><ion-title>Dynamic Header</ion-title></ion-toolbar>';
page.insertBefore(header, content);
}
}
function removeHeader() {
const page = document.getElementById('dynamic-page');
const header = page.querySelector('ion-header');
if (header) {
header.remove();
}
}
// Helper to open modal for testing
function openModal() {
document.getElementById('test-modal').isOpen = true;
}
</script>
</ion-app>
</body>
</html>

View File

@@ -419,6 +419,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.triggerController.removeClickListener();
this.cleanupViewTransitionListener();
this.cleanupParentRemovalObserver();
// Reset safe-area state to handle removal without dismiss (e.g., framework unmount)
this.resetSafeAreaState();
}
componentWillLoad() {
@@ -942,6 +944,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.updateFooterPadding();
// Watch for dynamic footer additions/removals (e.g., async data loading)
// Use subtree:true to support wrapped footers in framework components
// (e.g., <my-footer><ion-footer>...</ion-footer></my-footer>)
if (!this.footerObserver && win !== undefined && 'MutationObserver' in win) {
this.footerObserver = new MutationObserver(() => this.updateFooterPadding());
this.footerObserver.observe(this.el, { childList: true, subtree: true });
@@ -977,6 +981,32 @@ export class Modal implements ComponentInterface, OverlayInterface {
style.setProperty('--ion-safe-area-right', '0px');
}
/**
* Resets all safe-area related state and styles.
* Called during dismiss and disconnectedCallback to ensure clean state
* for re-presentation of inline modals.
*/
private resetSafeAreaState() {
this.skipSafeAreaCoordinateDetection = false;
this.cachedSafeAreas = undefined;
this.prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
this.footerObserver?.disconnect();
this.footerObserver = undefined;
// Clear wrapper styles that may have been set for safe-area handling
if (this.wrapperEl) {
this.wrapperEl.style.removeProperty('padding-bottom');
this.wrapperEl.style.removeProperty('box-sizing');
}
// Clear safe-area CSS variable overrides
const style = this.el.style;
style.removeProperty('--ion-safe-area-top');
style.removeProperty('--ion-safe-area-bottom');
style.removeProperty('--ion-safe-area-left');
style.removeProperty('--ion-safe-area-right');
}
/**
* Gets the root safe-area values from the document element.
* Uses cached values during gestures to avoid getComputedStyle calls.
@@ -1158,22 +1188,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.currentBreakpoint = undefined;
this.animation = undefined;
// Reset safe-area state for potential re-presentation
this.skipSafeAreaCoordinateDetection = false;
this.cachedSafeAreas = undefined;
this.prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
this.footerObserver?.disconnect();
this.footerObserver = undefined;
// Clear styles that may have been set for safe-area handling
if (this.wrapperEl) {
this.wrapperEl.style.removeProperty('padding-bottom');
this.wrapperEl.style.removeProperty('box-sizing');
}
// Clear safe-area CSS variable overrides
const style = this.el.style;
style.removeProperty('--ion-safe-area-top');
style.removeProperty('--ion-safe-area-bottom');
style.removeProperty('--ion-safe-area-left');
style.removeProperty('--ion-safe-area-right');
this.resetSafeAreaState();
unlock();

View File

@@ -1,4 +1,5 @@
import { expect } from '@playwright/test';
import type { E2EPage } from '@utils/test/playwright';
import { configs, test, Viewports } from '@utils/test/playwright';
/**
@@ -10,7 +11,7 @@ import { configs, test, Viewports } from '@utils/test/playwright';
*/
// Helper to get the modal wrapper's computed padding-bottom
async function getWrapperPaddingBottom(page: any): Promise<string> {
async function getWrapperPaddingBottom(page: E2EPage): Promise<string> {
const modal = page.locator('ion-modal');
return modal.evaluate((el: HTMLIonModalElement) => {
const wrapper = el.shadowRoot?.querySelector('.modal-wrapper');
@@ -20,7 +21,7 @@ async function getWrapperPaddingBottom(page: any): Promise<string> {
}
// Helper to check if modal has a footer
async function modalHasFooter(page: any): Promise<boolean> {
async function modalHasFooter(page: E2EPage): Promise<boolean> {
const modal = page.locator('ion-modal');
return modal.evaluate((el: HTMLIonModalElement) => {
return el.querySelector('ion-footer') !== null;
@@ -41,7 +42,12 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config
await page.goto('/src/components/modal/test/safe-area', config);
});
test('fullscreen modal without footer should have wrapper padding', async ({ page }) => {
test('fullscreen modal without footer should have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-no-footer');
@@ -55,7 +61,12 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config
expect(paddingBottom).toBe('34px');
});
test('fullscreen modal with footer should not have wrapper padding', async ({ page }) => {
test('fullscreen modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-modal');
@@ -69,7 +80,12 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config
expect(paddingBottom).toBe('0px');
});
test('default modal without footer should have wrapper padding on phone', async ({ page }) => {
test('default modal without footer should have wrapper padding on phone', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#default-no-footer');
@@ -89,7 +105,12 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await page.goto('/src/components/modal/test/safe-area', config);
});
test('card modal without footer should have wrapper padding on phone', async ({ page }) => {
test('card modal without footer should have wrapper padding on phone', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal-no-footer');
@@ -100,7 +121,12 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
expect(paddingBottom).toBe('34px');
});
test('card modal with footer should not have wrapper padding', async ({ page }) => {
test('card modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal');
@@ -123,7 +149,12 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config
await page.goto('/src/components/modal/test/safe-area', config);
});
test('default modal should not have wrapper padding on tablet', async ({ page }) => {
test('default modal should not have wrapper padding on tablet', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#default-modal');
@@ -134,7 +165,12 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config
expect(paddingBottom).toBe('0px');
});
test('fullscreen modal without footer should have wrapper padding on tablet', async ({ page }) => {
test('fullscreen modal without footer should have wrapper padding on tablet', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-no-footer');
@@ -144,7 +180,12 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config
expect(paddingBottom).toBe('34px');
});
test('fullscreen modal with footer should not have wrapper padding', async ({ page }) => {
test('fullscreen modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-modal');
@@ -163,7 +204,12 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await page.goto('/src/components/modal/test/safe-area', config);
});
test('card modal should not have wrapper padding on tablet', async ({ page }) => {
test('card modal should not have wrapper padding on tablet', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal');
@@ -187,7 +233,12 @@ configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config
await page.goto('/src/components/modal/test/safe-area', config);
});
test('sheet modal should not have wrapper padding (footer handles safe-area)', async ({ page }) => {
test('sheet modal should not have wrapper padding (footer handles safe-area)', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#sheet-modal-full');

View File

@@ -47,7 +47,17 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
const { originX, originY, top, left, bottom, checkSafeAreaTop, checkSafeAreaBottom } = calculateWindowAdjustment(
const {
originX,
originY,
top,
left,
bottom,
checkSafeAreaTop,
checkSafeAreaBottom,
checkSafeAreaLeft,
checkSafeAreaRight,
} = calculateWindowAdjustment(
side,
results.top,
results.left,
@@ -70,9 +80,12 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
*/
const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
let topValue = `${top}px`;
let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
let leftValue = `${left}px`;
if (checkSafeAreaTop) {
topValue = `${top}px${safeAreaTop}`;
@@ -80,6 +93,12 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
if (checkSafeAreaBottom && bottomValue !== undefined) {
bottomValue = `${bottom}px${safeAreaBottom}`;
}
if (checkSafeAreaLeft) {
leftValue = `${left}px${safeAreaLeft}`;
}
if (checkSafeAreaRight) {
leftValue = `${left}px${safeAreaRight}`;
}
const baseAnimation = createAnimation();
const backdropAnimation = createAnimation();
@@ -101,7 +120,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
.addElement(contentEl)
.beforeStyles({
top: `calc(${topValue} + var(--offset-y, 0px))`,
left: `calc(${left}px + var(--offset-x, 0px))`,
left: `calc(${leftValue} + var(--offset-x, 0px))`,
'transform-origin': `${originY} ${originX}`,
})
.beforeAddWrite(() => {

View File

@@ -9,7 +9,8 @@ import { configs, test } from '@utils/test/playwright';
* is enforced and apps can no longer opt out.
*/
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
// Tests that apply to both iOS and MD modes
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('popover: safe-area positioning'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/popover/test/safe-area', config);
@@ -41,6 +42,15 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
// This ensures the popover is positioned above the unsafe area
expect(bottomStyle).toContain('var(--ion-safe-area-bottom');
});
});
});
// iOS-specific tests
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('popover: safe-area positioning - ios specific'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/popover/test/safe-area', config);
});
test('floating popover should not have safe-area adjustments', async ({ page }) => {
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
@@ -64,36 +74,3 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
});
});
});
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('popover: safe-area positioning - md mode'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/popover/test/safe-area', config);
});
test('popover pinned to bottom should account for safe-area-bottom in position', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30900',
});
// Use a smaller viewport to force the popover to be constrained
await page.setViewportSize({ width: 375, height: 500 });
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#bottom-trigger');
await ionPopoverDidPresent.next();
// Target the specific popover
const popover = page.locator('ion-popover[trigger="bottom-trigger"]');
const popoverContent = popover.locator('.popover-content');
// Get the computed bottom style - should include safe-area calc
const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
// The bottom should include the safe-area-bottom CSS variable
expect(bottomStyle).toContain('var(--ion-safe-area-bottom');
});
});
});