diff --git a/core/src/components/content/content.scss b/core/src/components/content/content.scss index c9c21a830c..5f8b2afa83 100644 --- a/core/src/components/content/content.scss +++ b/core/src/components/content/content.scss @@ -235,38 +235,6 @@ } -// Content: Safe Area -// -------------------------------------------------- -// When content has no sibling header, offset from top safe-area. -// When content has no sibling footer/tab-bar, offset from bottom safe-area. -// Left/right safe-areas always apply to main content (for landscape notched devices). -// This prevents content from overlapping device safe areas (status bar, nav bar, notch). - -:host(.safe-area-top) #background-content, -:host(.safe-area-top) .inner-scroll { - top: var(--ion-safe-area-top, 0px); -} - -:host(.safe-area-bottom) #background-content, -:host(.safe-area-bottom) .inner-scroll { - bottom: var(--ion-safe-area-bottom, 0px); -} - -:host(.safe-area-left) #background-content, -:host(.safe-area-left) .inner-scroll { - /* stylelint-disable property-disallowed-list */ - left: var(--ion-safe-area-left, 0px); - /* stylelint-enable property-disallowed-list */ -} - -:host(.safe-area-right) #background-content, -:host(.safe-area-right) .inner-scroll { - /* stylelint-disable property-disallowed-list */ - right: var(--ion-safe-area-right, 0px); - /* stylelint-enable property-disallowed-list */ -} - - // Content: Fixed // -------------------------------------------------- diff --git a/core/src/components/content/content.tsx b/core/src/components/content/content.tsx index 4515ce44ea..361939f7c2 100644 --- a/core/src/components/content/content.tsx +++ b/core/src/components/content/content.tsx @@ -1,6 +1,5 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Build, Component, Element, Event, Host, Listen, Method, Prop, forceUpdate, h, readTask } from '@stencil/core'; -import { win } from '@utils/browser'; import { componentOnReady, hasLazyBuild, inheritAriaAttributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers'; import { isPlatform } from '@utils/platform'; @@ -37,19 +36,6 @@ export class Content implements ComponentInterface { private resizeTimeout: ReturnType | null = null; private inheritedAttributes: Attributes = {}; - /** - * Track whether this content has sibling header/footer elements. - * When absent, we need to apply safe-area padding directly. - */ - private hasHeader = false; - private hasFooter = false; - - /** Watches for dynamic header/footer changes in parent element */ - private parentMutationObserver?: MutationObserver; - - /** Watches for dynamic tab bar changes in ion-tabs */ - private tabsMutationObserver?: MutationObserver; - private tabsElement: HTMLElement | null = null; private tabsLoadCallback?: () => void; @@ -146,13 +132,7 @@ export class Content implements ComponentInterface { } connectedCallback() { - // Content is "main" if not inside menu/popover/modal and not nested in another ion-content - this.isMainContent = - this.el.closest('ion-menu, ion-popover, ion-modal') === null && - this.el.parentElement?.closest('ion-content') === null; - - // Detect sibling header/footer for safe-area handling - this.detectSiblingElements(); + this.isMainContent = this.el.closest('ion-menu, ion-popover, ion-modal') === null; /** * The fullscreen content offsets need to be @@ -184,109 +164,15 @@ export class Content implements ComponentInterface { * bubbles, we can catch any instances of child tab bars loading by listening * on IonTabs. */ - this.tabsLoadCallback = () => { - this.resize(); - // Re-detect footer when tab bar loads (it may not exist during initial detection) - this.updateSiblingDetection(); - forceUpdate(this); - }; + this.tabsLoadCallback = () => this.resize(); closestTabs.addEventListener('ionTabBarLoaded', this.tabsLoadCallback); } } } - /** - * Detects sibling ion-header and ion-footer elements and sets up - * a mutation observer to handle dynamic changes (e.g., conditional rendering). - */ - private detectSiblingElements() { - this.updateSiblingDetection(); - - // Watch for dynamic header/footer changes (common in React conditional rendering) - 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(); - // 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 }); - } - - // Watch for dynamic tab bar changes in ion-tabs (common in Angular conditional rendering) - const tabs = this.el.closest('ion-tabs'); - if (tabs && !this.tabsMutationObserver && win !== undefined && 'MutationObserver' in win) { - this.tabsMutationObserver = new MutationObserver(() => { - const prevHasFooter = this.hasFooter; - this.updateSiblingDetection(); - // Only trigger re-render if footer detection actually changed - if (prevHasFooter !== this.hasFooter) { - forceUpdate(this); - } - }); - this.tabsMutationObserver.observe(tabs, { childList: true }); - } - } - - /** - * Updates hasHeader/hasFooter based on current DOM state. - * Checks both direct siblings and elements wrapped in custom components - * (e.g., ...). - */ - private updateSiblingDetection() { - const parent = this.el.parentElement; - if (parent) { - // First check for direct ion-header/ion-footer siblings - this.hasHeader = parent.querySelector(':scope > ion-header') !== null; - this.hasFooter = parent.querySelector(':scope > ion-footer') !== null; - - // If not found, check if any sibling contains them (wrapped components) - if (!this.hasHeader) { - this.hasHeader = this.siblingContainsElement(parent, 'ion-header'); - } - if (!this.hasFooter) { - this.hasFooter = this.siblingContainsElement(parent, 'ion-footer'); - } - } - - // If no footer found, check if we're inside ion-tabs which has ion-tab-bar - if (!this.hasFooter) { - const tabs = this.el.closest('ion-tabs'); - if (tabs) { - this.hasFooter = tabs.querySelector(':scope > ion-tab-bar') !== null; - } - } - } - - /** - * Checks if any sibling element of ion-content contains the specified element. - * Only searches one level deep to avoid finding elements in nested pages. - */ - private siblingContainsElement(parent: Element, tagName: string): boolean { - for (const sibling of parent.children) { - // Skip ion-content itself - if (sibling === this.el) continue; - // Check if this sibling contains the target element as an immediate child - if (sibling.querySelector(`:scope > ${tagName}`) !== null) { - return true; - } - } - return false; - } - disconnectedCallback() { this.onScrollEnd(); - // Clean up mutation observers to prevent memory leaks - this.parentMutationObserver?.disconnect(); - this.parentMutationObserver = undefined; - this.tabsMutationObserver?.disconnect(); - this.tabsMutationObserver = undefined; - if (hasLazyBuild(this.el)) { /** * The event listener and tabs caches need to @@ -563,7 +449,7 @@ export class Content implements ComponentInterface { } render() { - const { fixedSlotPlacement, hasFooter, hasHeader, inheritedAttributes, isMainContent, scrollX, scrollY, el } = this; + const { fixedSlotPlacement, inheritedAttributes, isMainContent, scrollX, scrollY, el } = this; const rtl = isRTL(el) ? 'rtl' : 'ltr'; const mode = getIonMode(this); const forceOverscroll = this.shouldForceOverscroll(); @@ -579,10 +465,6 @@ export class Content implements ComponentInterface { 'content-sizing': hostContext('ion-popover', this.el), overscroll: forceOverscroll, [`content-${rtl}`]: true, - 'safe-area-top': isMainContent && !hasHeader, - 'safe-area-bottom': isMainContent && !hasFooter, - 'safe-area-left': isMainContent, - 'safe-area-right': isMainContent, })} style={{ '--offset-top': `${this.cTop}px`, diff --git a/core/src/components/content/test/safe-area/content.e2e.ts b/core/src/components/content/test/safe-area/content.e2e.ts deleted file mode 100644 index df48ebdfdf..0000000000 --- a/core/src/components/content/test/safe-area/content.e2e.ts +++ /dev/null @@ -1,270 +0,0 @@ -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. - * - * Safe-area class logic: - * - safe-area-top: main content without header - * - safe-area-bottom: main content without footer/tab-bar - * - safe-area-left: always on main content (for landscape notched devices) - * - safe-area-right: always on main content (for landscape notched devices) - * - * 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/); - // Left/right always apply to main content - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - }); - - 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/); - // Left/right always apply to main content - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - }); - - test('content with both header and footer should not have top/bottom 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/); - // Left/right still apply to main content even with header/footer - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - }); - - test('content without header or footer should have all 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/); - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - }); - - 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/); - // Left/right still apply to main content - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - }); - - 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/); - // Left/right still apply to main content - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - }); - - test('nested content should not have any 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 - no safe-area classes at all - await expect(nestedContent).not.toHaveClass(/safe-area-top/); - await expect(nestedContent).not.toHaveClass(/safe-area-bottom/); - await expect(nestedContent).not.toHaveClass(/safe-area-left/); - await expect(nestedContent).not.toHaveClass(/safe-area-right/); - }); - - test('outer content should have all 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 all safe-area classes - await expect(outerContent).toHaveClass(/safe-area-top/); - await expect(outerContent).toHaveClass(/safe-area-bottom/); - await expect(outerContent).toHaveClass(/safe-area-left/); - await expect(outerContent).toHaveClass(/safe-area-right/); - }); - - test('content inside modal should not have any safe-area classes', async ({ page }, testInfo) => { - testInfo.annotations.push({ - type: 'issue', - description: 'https://outsystemsrd.atlassian.net/browse/FW-6830', - }); - - // Set up event spy BEFORE opening modal - const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); - - // Open the modal - await page.evaluate(() => { - const modal = document.getElementById('test-modal') as HTMLIonModalElement; - modal.isOpen = true; - }); - - // Wait for modal to be presented - await ionModalDidPresent.next(); - - const modalContent = page.locator('#content-in-modal'); - // Content inside modal should not be treated as main content - no safe-area classes at all - await expect(modalContent).not.toHaveClass(/safe-area-top/); - await expect(modalContent).not.toHaveClass(/safe-area-bottom/); - await expect(modalContent).not.toHaveClass(/safe-area-left/); - await expect(modalContent).not.toHaveClass(/safe-area-right/); - }); - - 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) and left/right (always on main content) - await expect(content).toHaveClass(/safe-area-top/); - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - - // Add header dynamically (use evaluate to avoid pointer-events issues in Firefox) - await page.evaluate(() => (window as any).addHeader()); - - // Wait for mutation observer to trigger and component to update - await expect(content).not.toHaveClass(/safe-area-top/, { timeout: 1000 }); - // Left/right should remain - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - }); - - 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 (use evaluate to avoid pointer-events issues in Firefox) - await page.evaluate(() => (window as any).addHeader()); - await expect(content).not.toHaveClass(/safe-area-top/, { timeout: 1000 }); - // Left/right should remain throughout - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - - // Remove header - await page.evaluate(() => (window as any).removeHeader()); - - // Should have safe-area-top again, left/right should remain - await expect(content).toHaveClass(/safe-area-top/, { timeout: 1000 }); - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - }); - - test('content inside ion-tabs with tab bar should not have safe-area-bottom', async ({ page }, testInfo) => { - testInfo.annotations.push({ - type: 'issue', - description: 'https://github.com/ionic-team/ionic-framework/issues/30900', - }); - - const content = page.locator('#content-dynamic-tabs'); - // Tab bar is present, so content should not have safe-area-bottom - await expect(content).not.toHaveClass(/safe-area-bottom/); - // But left/right should still apply (main content) - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - }); - - test('dynamic tab bar removal should update safe-area classes', async ({ page }, testInfo) => { - testInfo.annotations.push({ - type: 'issue', - description: 'https://github.com/ionic-team/ionic-framework/issues/30900', - }); - - const content = page.locator('#content-dynamic-tabs'); - - // Initially tab bar is present, so no safe-area-bottom - await expect(content).not.toHaveClass(/safe-area-bottom/); - // Left/right should be present throughout - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - - // Remove tab bar - await page.evaluate(() => (window as any).removeTabBar()); - - // Should have safe-area-bottom now, left/right remain - await expect(content).toHaveClass(/safe-area-bottom/, { timeout: 1000 }); - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - }); - - test('dynamic tab bar addition should update safe-area classes', async ({ page }, testInfo) => { - testInfo.annotations.push({ - type: 'issue', - description: 'https://github.com/ionic-team/ionic-framework/issues/30900', - }); - - const content = page.locator('#content-dynamic-tabs'); - - // Remove tab bar first - await page.evaluate(() => (window as any).removeTabBar()); - await expect(content).toHaveClass(/safe-area-bottom/, { timeout: 1000 }); - // Left/right should be present throughout - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - - // Add tab bar back - await page.evaluate(() => (window as any).addTabBar()); - - // Should not have safe-area-bottom anymore, left/right remain - await expect(content).not.toHaveClass(/safe-area-bottom/, { timeout: 1000 }); - await expect(content).toHaveClass(/safe-area-left/); - await expect(content).toHaveClass(/safe-area-right/); - }); - }); -}); diff --git a/core/src/components/content/test/safe-area/index.html b/core/src/components/content/test/safe-area/index.html deleted file mode 100644 index 0ff8fea11f..0000000000 --- a/core/src/components/content/test/safe-area/index.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - 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

- - -
-
-
- - -
- -
- -

Content with dynamic tab bar

-
-
- - - Tab 1 - - -
-
- - -
- -