diff --git a/core/src/components/content/content.tsx b/core/src/components/content/content.tsx index 87e2c5938e..947c4104d5 100644 --- a/core/src/components/content/content.tsx +++ b/core/src/components/content/content.tsx @@ -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 }); } diff --git a/core/src/components/content/test/safe-area/content.e2e.ts b/core/src/components/content/test/safe-area/content.e2e.ts new file mode 100644 index 0000000000..0255ac74bb --- /dev/null +++ b/core/src/components/content/test/safe-area/content.e2e.ts @@ -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 }); + }); + }); +}); diff --git a/core/src/components/content/test/safe-area/index.html b/core/src/components/content/test/safe-area/index.html new file mode 100644 index 0000000000..1390ac9e9c --- /dev/null +++ b/core/src/components/content/test/safe-area/index.html @@ -0,0 +1,192 @@ + + + + + Content - Safe Area + + + + + + + + + + + + +
+
+ +

Content without header - should have safe-area-top class

+
+ + + Footer + + +
+
+ + + + + +
+
+ + + Header + + + +

Content with both header and footer - should NOT have safe-area classes

+
+ + + Footer + + +
+
+ + +
+
+ +

Content without header or footer - should have both safe-area classes

+
+
+
+ + +
+
+ + + + Wrapped Header + + + + +

Content with wrapped header - should NOT have safe-area-top class

+
+
+
+ + + + + +
+
+ +

Outer content

+
+ +

Nested content - should NOT have safe-area classes

+
+
+
+
+
+ + + + +

Content inside modal - should NOT have safe-area classes

+
+
+ + +
+
+ +

Content with dynamic header/footer

+ + +
+
+
+ + +
+ + diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 00a240400d..bf57c36f07 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -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., ...) 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(); diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts b/core/src/components/modal/test/safe-area/modal.e2e.ts index d317c85ae7..39bd2bb246 100644 --- a/core/src/components/modal/test/safe-area/modal.e2e.ts +++ b/core/src/components/modal/test/safe-area/modal.e2e.ts @@ -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 { +async function getWrapperPaddingBottom(page: E2EPage): Promise { 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 { } // Helper to check if modal has a footer -async function modalHasFooter(page: any): Promise { +async function modalHasFooter(page: E2EPage): Promise { 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'); diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts index 31ec53ec07..e8a1e1adc5 100644 --- a/core/src/components/popover/animations/md.enter.ts +++ b/core/src/components/popover/animations/md.enter.ts @@ -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(() => { diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts b/core/src/components/popover/test/safe-area/popover.e2e.ts index b8d4272f87..13658a256d 100644 --- a/core/src/components/popover/test/safe-area/popover.e2e.ts +++ b/core/src/components/popover/test/safe-area/popover.e2e.ts @@ -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'); - }); - }); -});