mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
fix(modal): addressing edge cases, cleaning up
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
167
core/src/components/content/test/safe-area/content.e2e.ts
Normal file
167
core/src/components/content/test/safe-area/content.e2e.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
192
core/src/components/content/test/safe-area/index.html
Normal file
192
core/src/components/content/test/safe-area/index.html
Normal 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>
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user