diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Chrome-linux.png index 51197814d40..6649d287abc 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Firefox-linux.png index d6978bd6ab2..80888d1a92a 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Safari-linux.png index 7b6912a77b6..b419d347267 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Chrome-linux.png index 9bdb2a0050d..2b40e11d46e 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Firefox-linux.png index 2160bf71c71..047802682a9 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Safari-linux.png index e18e09840ac..0fb962207b0 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Safari-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-action-sheet-diff-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Chrome-linux.png index 2925d01cc40..de12b07531a 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Firefox-linux.png index 625d61957f4..d97458eca22 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Safari-linux.png index 636bbd74326..36b86d17cb2 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Chrome-linux.png index 6bf467070f7..7204e093f23 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Firefox-linux.png index 31175c53c52..5ab026c6584 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Safari-linux.png index d1e17793401..c4ac3d995cc 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Safari-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-menu-diff-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Chrome-linux.png index a72dc772243..fa223cda58a 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Firefox-linux.png index 6796d9a484f..aed2fb2e87c 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Safari-linux.png index 2d747790320..c1d4d022117 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Chrome-linux.png index dd8e90750f3..bc3c61c3903 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Firefox-linux.png index eaa50d6e5fb..ca5734b52e1 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Safari-linux.png index a291fe4c078..e039cfd894c 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Safari-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-picker-diff-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Chrome-linux.png index 2217a13a189..9e26377225e 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Firefox-linux.png index 6d49226ac1d..6e8bb771322 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Safari-linux.png index 3619f94220f..4003343ef16 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Chrome-linux.png index 69df91ae522..150ef9324cd 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Firefox-linux.png index e8afb2df67d..4d0555492a3 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Safari-linux.png index fd6120b1681..ab77d49fe3b 100644 Binary files a/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Safari-linux.png and b/core/src/components/app/test/safe-area/app.e2e.ts-snapshots/app-toast-diff-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/content/content.scss b/core/src/components/content/content.scss index 5f8b2afa831..c9c21a830c8 100644 --- a/core/src/components/content/content.scss +++ b/core/src/components/content/content.scss @@ -235,6 +235,38 @@ } +// 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 361939f7c27..4515ce44ea8 100644 --- a/core/src/components/content/content.tsx +++ b/core/src/components/content/content.tsx @@ -1,5 +1,6 @@ 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'; @@ -36,6 +37,19 @@ 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; @@ -132,7 +146,13 @@ export class Content implements ComponentInterface { } connectedCallback() { - this.isMainContent = this.el.closest('ion-menu, ion-popover, ion-modal') === null; + // 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(); /** * The fullscreen content offsets need to be @@ -164,15 +184,109 @@ export class Content implements ComponentInterface { * bubbles, we can catch any instances of child tab bars loading by listening * on IonTabs. */ - this.tabsLoadCallback = () => this.resize(); + this.tabsLoadCallback = () => { + this.resize(); + // Re-detect footer when tab bar loads (it may not exist during initial detection) + this.updateSiblingDetection(); + forceUpdate(this); + }; 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 @@ -449,7 +563,7 @@ export class Content implements ComponentInterface { } render() { - const { fixedSlotPlacement, inheritedAttributes, isMainContent, scrollX, scrollY, el } = this; + const { fixedSlotPlacement, hasFooter, hasHeader, inheritedAttributes, isMainContent, scrollX, scrollY, el } = this; const rtl = isRTL(el) ? 'rtl' : 'ltr'; const mode = getIonMode(this); const forceOverscroll = this.shouldForceOverscroll(); @@ -465,6 +579,10 @@ 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 new file mode 100644 index 00000000000..df48ebdfdfa --- /dev/null +++ b/core/src/components/content/test/safe-area/content.e2e.ts @@ -0,0 +1,270 @@ +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 new file mode 100644 index 00000000000..0ff8fea11f7 --- /dev/null +++ b/core/src/components/content/test/safe-area/index.html @@ -0,0 +1,227 @@ + + + + + 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 + + +
+
+ + +
+ + diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Chrome-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Chrome-linux.png index aee611bf378..62027fbd98b 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Firefox-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Firefox-linux.png index 9f3d30bfff5..67f7fa87e9e 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Safari-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Safari-linux.png index 3a851565133..ca2fae7c63b 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Safari-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Chrome-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Chrome-linux.png index 9660aeeadcd..8621bf6f6f5 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Firefox-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Firefox-linux.png index 6c1d1ee97b7..3d9222f488e 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Safari-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Safari-linux.png index 35e0df2fb84..22d7ad0cd90 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Safari-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-end-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Chrome-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Chrome-linux.png index 9660aeeadcd..8621bf6f6f5 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Firefox-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Firefox-linux.png index 6c1d1ee97b7..3d9222f488e 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Safari-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Safari-linux.png index 35e0df2fb84..22d7ad0cd90 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Safari-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Chrome-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Chrome-linux.png index aee611bf378..62027fbd98b 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Firefox-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Firefox-linux.png index 9f3d30bfff5..67f7fa87e9e 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Safari-linux.png b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Safari-linux.png index 3a851565133..ca2fae7c63b 100644 Binary files a/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Safari-linux.png and b/core/src/components/fab/test/safe-area/fab.e2e.ts-snapshots/fab-safe-area-horizontal-start-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Chrome-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Chrome-linux.png index d23db0f8082..7ab79b6b2f9 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Firefox-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Firefox-linux.png index dec6cbe80d4..1b3df566496 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Safari-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Safari-linux.png index b526f0f0a61..5c1a10abd52 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Safari-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Chrome-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Chrome-linux.png index a690482d70c..5ae2be37fcd 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Firefox-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Firefox-linux.png index 78668e8a1c7..a3f5c572169 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Safari-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Safari-linux.png index a2d9893fc26..d9cd1701b97 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Safari-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-left-notch-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Chrome-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Chrome-linux.png index 9303d7426af..4b731a0dc9b 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Firefox-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Firefox-linux.png index f364b05c25c..11115de6c28 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Safari-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Safari-linux.png index a7168d36400..a803e95a8bc 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Safari-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Chrome-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Chrome-linux.png index 58c28a228f8..e895f7e487e 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Firefox-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Firefox-linux.png index 927087d578b..89a43383562 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Safari-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Safari-linux.png index 221ed10fc32..1a53393e780 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Safari-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-end-safe-area-right-notch-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Chrome-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Chrome-linux.png index 30038ec4c37..9b9f62811a2 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Firefox-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Firefox-linux.png index 3d9d4b61321..98cbf701c8a 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Safari-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Safari-linux.png index 36580ae58cb..8f4e68ff364 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Safari-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Chrome-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Chrome-linux.png index 6c5d9946a2e..f6dffec69c4 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Firefox-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Firefox-linux.png index 3cb029908fa..375fd4b50a6 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Safari-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Safari-linux.png index 52e0ca5ceb0..6f4febcac02 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Safari-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-left-notch-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Chrome-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Chrome-linux.png index 1f9094154ab..5798b6306f1 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Firefox-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Firefox-linux.png index 4e89bfd3183..83f08e457f7 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Safari-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Safari-linux.png index c289cff38b8..bc1db64575b 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Safari-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Chrome-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Chrome-linux.png index a9689438292..ccc9e2132f4 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Firefox-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Firefox-linux.png index f7f75c751fb..45e92351321 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Safari-linux.png b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Safari-linux.png index a8ebda13d1e..ecda29a8bf3 100644 Binary files a/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Safari-linux.png and b/core/src/components/menu/test/safe-area/menu.e2e.ts-snapshots/menu-start-safe-area-right-notch-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index afac8f3d3ed..a9f7855e4d4 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -52,7 +52,8 @@ export const createSheetGesture = ( expandToScroll: boolean, getCurrentBreakpoint: () => number, onDismiss: () => void, - onBreakpointChange: (breakpoint: number) => void + onBreakpointChange: (breakpoint: number) => void, + onGestureMove?: () => void ) => { // Defaults for the sheet swipe animation const defaultBackdrop = [ @@ -423,6 +424,9 @@ export const createSheetGesture = ( offset = clamp(0.0001, processedStep, maxStep); animation.progressStep(offset); + + // Notify modal of position change for safe-area updates + onGestureMove?.(); }; const onEnd = (detail: GestureDetail) => { diff --git a/core/src/components/modal/gestures/swipe-to-close.ts b/core/src/components/modal/gestures/swipe-to-close.ts index 17ec454ff15..c81a6a6ba21 100644 --- a/core/src/components/modal/gestures/swipe-to-close.ts +++ b/core/src/components/modal/gestures/swipe-to-close.ts @@ -20,7 +20,8 @@ export const createSwipeToCloseGesture = ( el: HTMLIonModalElement, animation: Animation, statusBarStyle: StatusBarStyle, - onDismiss: () => void + onDismiss: () => void, + onGestureMove?: () => void ) => { /** * The step value at which a card modal @@ -199,6 +200,9 @@ export const createSwipeToCloseGesture = ( animation.progressStep(clampedStep); + // Notify modal of position change for safe-area updates + onGestureMove?.(); + /** * When swiping down half way, the status bar style * should be reset to its default value. diff --git a/core/src/components/modal/modal.scss b/core/src/components/modal/modal.scss index 7c5ec7916fe..ac4cb533b48 100644 --- a/core/src/components/modal/modal.scss +++ b/core/src/components/modal/modal.scss @@ -94,10 +94,6 @@ ion-backdrop { :host { --width: #{$modal-inset-width}; --height: #{$modal-inset-height-small}; - --ion-safe-area-top: 0px; - --ion-safe-area-bottom: 0px; - --ion-safe-area-right: 0px; - --ion-safe-area-left: 0px; } } diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index a96d59c8e9f..bf57c36f073 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h, writeTask } from '@stencil/core'; +import { win } from '@utils/browser'; import { findIonContent, printIonContentErrorMsg } from '@utils/content'; import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate'; import { raf, inheritAttributes, hasLazyBuild, getElementRoot } from '@utils/helpers'; @@ -98,10 +99,18 @@ export class Modal implements ComponentInterface, OverlayInterface { // Mutation observer to watch for parent removal private parentRemovalObserver?: MutationObserver; + // Watches for dynamic footer additions/removals to update safe-area padding + private footerObserver?: MutationObserver; // Cached original parent from before modal is moved to body during presentation private cachedOriginalParent?: HTMLElement; // Cached ion-page ancestor for child route passthrough private cachedPageParent?: HTMLElement | null; + // Whether to skip coordinate-based safe-area detection (for fullscreen phone modals) + private skipSafeAreaCoordinateDetection = false; + // Cached safe-area values to avoid getComputedStyle calls during gestures + private cachedSafeAreas?: { top: number; bottom: number; left: number; right: number }; + // Track previous safe-area state to avoid redundant DOM writes + private prevSafeAreaState = { top: false, bottom: false, left: false, right: false }; lastFocus?: HTMLElement; animation?: Animation; @@ -276,7 +285,11 @@ export class Modal implements ComponentInterface, OverlayInterface { @Listen('resize', { target: 'window' }) onWindowResize() { - // Only handle resize for iOS card modals when no custom animations are provided + // Invalidate safe-area cache on resize (device rotation may change values) + this.cachedSafeAreas = undefined; + this.updateSafeAreaOverrides(); + + // Only handle view transition for iOS card modals when no custom animations are provided if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) { return; } @@ -406,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() { @@ -592,6 +607,9 @@ export class Modal implements ComponentInterface, OverlayInterface { await waitForMount(); } + // Predict safe-area needs based on modal configuration to avoid visual snap + this.setInitialSafeAreaOverrides(presentingElement); + writeTask(() => this.el.classList.add('show-modal')); const hasCardModal = presentingElement !== undefined; @@ -659,6 +677,9 @@ export class Modal implements ComponentInterface, OverlayInterface { this.initSwipeToClose(); } + // Now that animation is complete, update safe-area based on actual position + this.updateSafeAreaOverrides(); + // Initialize view transition listener for iOS card modals this.initViewTransitionListener(); @@ -692,33 +713,39 @@ export class Modal implements ComponentInterface, OverlayInterface { const statusBarStyle = this.statusBarStyle ?? StatusBarStyle.Default; - this.gesture = createSwipeToCloseGesture(el, ani, statusBarStyle, () => { - /** - * While the gesture animation is finishing - * it is possible for a user to tap the backdrop. - * This would result in the dismiss animation - * being played again. Typically this is avoided - * by setting `presented = false` on the overlay - * component; however, we cannot do that here as - * that would prevent the element from being - * removed from the DOM. - */ - this.gestureAnimationDismissing = true; - - /** - * Reset the status bar style as the dismiss animation - * starts otherwise the status bar will be the wrong - * color for the duration of the dismiss animation. - * The dismiss method does this as well, but - * in this case it's only called once the animation - * has finished. - */ - setCardStatusBarDefault(this.statusBarStyle); - this.animation!.onFinish(async () => { - await this.dismiss(undefined, GESTURE); - this.gestureAnimationDismissing = false; - }); - }); + this.gesture = createSwipeToCloseGesture( + el, + ani, + statusBarStyle, + () => { + /** + * While the gesture animation is finishing + * it is possible for a user to tap the backdrop. + * This would result in the dismiss animation + * being played again. Typically this is avoided + * by setting `presented = false` on the overlay + * component; however, we cannot do that here as + * that would prevent the element from being + * removed from the DOM. + */ + this.gestureAnimationDismissing = true; + + /** + * Reset the status bar style as the dismiss animation + * starts otherwise the status bar will be the wrong + * color for the duration of the dismiss animation. + * The dismiss method does this as well, but + * in this case it's only called once the animation + * has finished. + */ + setCardStatusBarDefault(this.statusBarStyle); + this.animation!.onFinish(async () => { + await this.dismiss(undefined, GESTURE); + this.gestureAnimationDismissing = false; + }); + }, + () => this.updateSafeAreaOverrides() + ); this.gesture.enable(true); } @@ -755,7 +782,9 @@ export class Modal implements ComponentInterface, OverlayInterface { this.currentBreakpoint = breakpoint; this.ionBreakpointDidChange.emit({ breakpoint }); } - } + this.updateSafeAreaOverrides(); + }, + () => this.updateSafeAreaOverrides() ); this.gesture = gesture; @@ -849,6 +878,203 @@ export class Modal implements ComponentInterface, OverlayInterface { this.cachedPageParent = undefined; } + /** + * Sets initial safe-area overrides based on modal configuration before + * the modal becomes visible. This predicts whether the modal will touch + * screen edges to avoid a visual snap after animation completes. + */ + private setInitialSafeAreaOverrides(presentingElement: HTMLElement | undefined) { + const style = this.el.style; + const mode = getIonMode(this); + const isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined; + // Card modals only exist in iOS mode - in MD mode, presentingElement is ignored + const isCardModal = presentingElement !== undefined && mode === 'ios'; + const isTablet = window.innerWidth >= 768; + + // Sheet modals always touch bottom edge, never top/left/right + if (isSheetModal) { + style.setProperty('--ion-safe-area-top', '0px'); + style.setProperty('--ion-safe-area-left', '0px'); + style.setProperty('--ion-safe-area-right', '0px'); + return; + } + + // Card modals have rounded top corners + if (isCardModal) { + style.setProperty('--ion-safe-area-top', '0px'); + if (isTablet) { + // On tablets, card modals are inset from all edges + this.zeroAllSafeAreas(); + } else { + // On phones, card modals still extend to the bottom edge + style.setProperty('--ion-safe-area-left', '0px'); + style.setProperty('--ion-safe-area-right', '0px'); + this.applyFullscreenSafeArea(); + } + return; + } + + // Phone-sized fullscreen modals inherit safe areas and use wrapper padding + if (!isTablet) { + this.applyFullscreenSafeArea(); + return; + } + + // Check if tablet modal is fullscreen via CSS custom properties + const computedStyle = getComputedStyle(this.el); + const width = computedStyle.getPropertyValue('--width').trim(); + const height = computedStyle.getPropertyValue('--height').trim(); + const isFullscreen = width === '100%' && height === '100%'; + + if (isFullscreen) { + this.applyFullscreenSafeArea(); + } else { + // Centered dialog doesn't touch edges + this.zeroAllSafeAreas(); + } + } + + /** + * Applies safe-area handling for fullscreen modals. + * Adds wrapper padding when no footer is present to prevent + * content from overlapping system navigation areas. + */ + private applyFullscreenSafeArea() { + this.skipSafeAreaCoordinateDetection = true; + 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 }); + } + } + + /** + * Updates wrapper padding based on footer presence. + * Called initially and when footer is dynamically added/removed. + */ + private updateFooterPadding() { + if (!this.wrapperEl) return; + + const hasFooter = this.el.querySelector('ion-footer') !== null; + if (hasFooter) { + this.wrapperEl.style.removeProperty('padding-bottom'); + this.wrapperEl.style.removeProperty('box-sizing'); + } else { + this.wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)'); + this.wrapperEl.style.setProperty('box-sizing', 'border-box'); + } + } + + /** + * Sets all safe-area CSS variables to 0px for modals that + * don't touch screen edges. + */ + private zeroAllSafeAreas() { + const style = this.el.style; + style.setProperty('--ion-safe-area-top', '0px'); + style.setProperty('--ion-safe-area-bottom', '0px'); + style.setProperty('--ion-safe-area-left', '0px'); + 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. + */ + private getSafeAreaValues(): { top: number; bottom: number; left: number; right: number } { + if (!this.cachedSafeAreas) { + const rootStyle = getComputedStyle(document.documentElement); + this.cachedSafeAreas = { + top: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-top')) || 0, + bottom: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-bottom')) || 0, + left: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-left')) || 0, + right: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-right')) || 0, + }; + } + return this.cachedSafeAreas; + } + + /** + * Updates safe-area CSS variable overrides based on whether the modal + * extends into each safe-area region. Called after animation + * and during gestures to handle dynamic position changes. + * + * Optimized to avoid redundant DOM writes by tracking previous state. + */ + private updateSafeAreaOverrides() { + if (this.skipSafeAreaCoordinateDetection) { + return; + } + + const wrapper = this.wrapperEl; + if (!wrapper) { + return; + } + + const rect = wrapper.getBoundingClientRect(); + const safeAreas = this.getSafeAreaValues(); + + const extendsIntoTop = rect.top < safeAreas.top; + const extendsIntoBottom = rect.bottom > window.innerHeight - safeAreas.bottom; + const extendsIntoLeft = rect.left < safeAreas.left; + const extendsIntoRight = rect.right > window.innerWidth - safeAreas.right; + + // Only update DOM when state actually changes + const prev = this.prevSafeAreaState; + const style = this.el.style; + + if (extendsIntoTop !== prev.top) { + extendsIntoTop ? style.removeProperty('--ion-safe-area-top') : style.setProperty('--ion-safe-area-top', '0px'); + prev.top = extendsIntoTop; + } + if (extendsIntoBottom !== prev.bottom) { + extendsIntoBottom + ? style.removeProperty('--ion-safe-area-bottom') + : style.setProperty('--ion-safe-area-bottom', '0px'); + prev.bottom = extendsIntoBottom; + } + if (extendsIntoLeft !== prev.left) { + extendsIntoLeft ? style.removeProperty('--ion-safe-area-left') : style.setProperty('--ion-safe-area-left', '0px'); + prev.left = extendsIntoLeft; + } + if (extendsIntoRight !== prev.right) { + extendsIntoRight + ? style.removeProperty('--ion-safe-area-right') + : style.setProperty('--ion-safe-area-right', '0px'); + prev.right = extendsIntoRight; + } + } + private sheetOnDismiss() { /** * While the gesture animation is finishing @@ -961,6 +1187,8 @@ export class Modal implements ComponentInterface, OverlayInterface { } this.currentBreakpoint = undefined; this.animation = undefined; + // Reset safe-area state for potential re-presentation + this.resetSafeAreaState(); unlock(); diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Chrome-linux.png index dcd19712e99..4a1d7e4c181 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Firefox-linux.png index aa87ce45424..e009b602e6e 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Safari-linux.png index eba8d8a1d64..4426db18d88 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Chrome-linux.png index d627a28c705..bd5bb863d7d 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Firefox-linux.png index 90c3297b3e6..d31a35e0aec 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Firefox-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Safari-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Safari-linux.png index d0d31492ed3..d0ee1cce415 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Safari-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Chrome-linux.png index 3013b111828..feb5f880212 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Firefox-linux.png index e9ab0e35017..7e82447a557 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Safari-linux.png index f484160464c..228bf01cb39 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Safari-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Chrome-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Chrome-linux.png index 50a1560e71a..a4a9ea7d1e9 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Firefox-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Firefox-linux.png index aa3076fd43a..dcc9ef07a8b 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Safari-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Safari-linux.png index 15896e6b684..b94892d71ef 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Safari-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Chrome-linux.png index a91b1bf3bb4..b1113907fc2 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Firefox-linux.png index a2ea869f72a..5dc0ba25e81 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Safari-linux.png index b56f30abc74..367217cc6a4 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Chrome-linux.png index bd83c2164c9..19879dbbcef 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Firefox-linux.png index 1e264be58e1..d10916b9949 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Firefox-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Safari-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Safari-linux.png index 65fab21a0c7..157af1e1d20 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Safari-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Chrome-linux.png index 2ac52f2d9c6..36e0601a42f 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Firefox-linux.png index cd03d981152..d68b8a63324 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Safari-linux.png index 0faf177715b..8a444ae25bc 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Safari-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Chrome-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Chrome-linux.png index d2b1d607bf6..e80168d2cbd 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Firefox-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Firefox-linux.png index 2b7093b0eb4..07f0e341a4e 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Safari-linux.png b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Safari-linux.png index 83c959aa1f4..4afd68c0186 100644 Binary files a/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Safari-linux.png and b/core/src/components/modal/test/basic/modal.e2e.ts-snapshots/modal-basic-present-tablet-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Chrome-linux.png index f188eb46258..44929c42bed 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Firefox-linux.png index 9c0ef9a90c4..8d61f2883cb 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Safari-linux.png index d45c49fe7fe..4399b2afa3b 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Chrome-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Chrome-linux.png index 599fbd77e76..7393cf7a796 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Chrome-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Firefox-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Firefox-linux.png index eb7dff3ea17..446e0d64206 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Firefox-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Safari-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Safari-linux.png index a3317be484b..e37a220e0a2 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Safari-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-ios-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Chrome-linux.png index a9538fbdab2..311e61d141c 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Firefox-linux.png index 6ab9a6430e6..318a8b4bea6 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Safari-linux.png index ac69eb9a371..5ba4557d9da 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Safari-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Chrome-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Chrome-linux.png index dca58f00ee6..8e140f3c714 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Firefox-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Firefox-linux.png index 79d4376a7e7..05d96cb99ba 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Safari-linux.png b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Safari-linux.png index 3c08445786a..0fadc9eb0f1 100644 Binary files a/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Safari-linux.png and b/core/src/components/modal/test/custom/modal.e2e.ts-snapshots/modal-custom-present-md-rtl-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Chrome-linux.png index 35395c2f9b1..d9ef05c11e8 100644 Binary files a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Firefox-linux.png index c25f3f922d1..cc754a6685d 100644 Binary files a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Safari-linux.png index 82482d6927e..dc426c19186 100644 Binary files a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Chrome-linux.png index 5b93a9766fc..aec170314cb 100644 Binary files a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Firefox-linux.png index 6a15fab242b..56aad283993 100644 Binary files a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Safari-linux.png index b358d9fa5f9..9fa0d3587a7 100644 Binary files a/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Safari-linux.png and b/core/src/components/modal/test/dark-mode/model.e2e.ts-snapshots/modal-dark-color-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/safe-area/index.html b/core/src/components/modal/test/safe-area/index.html new file mode 100644 index 00000000000..fee7e96d079 --- /dev/null +++ b/core/src/components/modal/test/safe-area/index.html @@ -0,0 +1,263 @@ + + + + + Modal - Safe Area + + + + + + + + + + + +
+ + + Modal - Safe Area + + + + +

Test safe-area handling in modals.

+ + + + + With Footer + + + + +

Default Modal

+

Centered dialog on tablet - should NOT have safe-area padding

+
+ Present +
+ + + +

Fullscreen Modal

+

Full screen - footer handles safe-area

+
+ Present +
+ + + +

Sheet Modal (Partial)

+

At 0.5 breakpoint - should have bottom safe-area only

+
+ Present +
+ + + +

Sheet Modal (Full)

+

At 1.0 breakpoint - should have bottom safe-area

+
+ Present +
+ + + +

Card Modal (iOS)

+

Card presentation with presentingElement

+
+ Present +
+
+ + + + Without Footer (wrapper padding) + + + + +

Fullscreen Modal (no footer)

+

Wrapper padding should prevent content overlap

+
+ Present +
+ + + +

Card Modal (no footer)

+

On phones, wrapper padding should prevent content overlap

+
+ Present +
+ + + +

Default Modal (no footer)

+

On phones, wrapper padding should prevent content overlap

+
+ Present +
+
+
+
+
+
+ + + + diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts b/core/src/components/modal/test/safe-area/modal.e2e.ts new file mode 100644 index 00000000000..39bd2bb2467 --- /dev/null +++ b/core/src/components/modal/test/safe-area/modal.e2e.ts @@ -0,0 +1,252 @@ +import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; +import { configs, test, Viewports } from '@utils/test/playwright'; + +/** + * Safe-area tests verify that modals correctly handle safe-area insets + * based on modal type and screen size. + * + * These tests use simulated safe-area values (34px bottom) set in index.html. + * They verify the modal wrapper has correct padding applied. + */ + +// Helper to get the modal wrapper's computed padding-bottom +async function getWrapperPaddingBottom(page: E2EPage): Promise { + const modal = page.locator('ion-modal'); + return modal.evaluate((el: HTMLIonModalElement) => { + const wrapper = el.shadowRoot?.querySelector('.modal-wrapper'); + if (!wrapper) return '0px'; + return getComputedStyle(wrapper).paddingBottom; + }); +} + +// Helper to check if modal has a footer +async function modalHasFooter(page: E2EPage): Promise { + const modal = page.locator('ion-modal'); + return modal.evaluate((el: HTMLIonModalElement) => { + return el.querySelector('ion-footer') !== null; + }); +} + +// Phone viewport (less than 768px width) +const PhoneViewport = { width: 390, height: 844 }; + +// ============================================================================= +// Phone Tests - Fullscreen modals need wrapper padding when no footer +// ============================================================================= + +configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('modal: safe-area - phone'), () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize(PhoneViewport); + await page.goto('/src/components/modal/test/safe-area', config); + }); + + 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'); + await ionModalDidPresent.next(); + + const hasFooter = await modalHasFooter(page); + expect(hasFooter).toBe(false); + + const paddingBottom = await getWrapperPaddingBottom(page); + // Should have safe-area padding (34px as set in test HTML) + expect(paddingBottom).toBe('34px'); + }); + + 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'); + await ionModalDidPresent.next(); + + const hasFooter = await modalHasFooter(page); + expect(hasFooter).toBe(true); + + const paddingBottom = await getWrapperPaddingBottom(page); + // Footer handles safe-area, wrapper should have no padding + expect(paddingBottom).toBe('0px'); + }); + + 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'); + await ionModalDidPresent.next(); + + // On phones, default modals are fullscreen + const paddingBottom = await getWrapperPaddingBottom(page); + expect(paddingBottom).toBe('34px'); + }); + }); +}); + +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('modal: safe-area - card modal on phone'), () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize(PhoneViewport); + await page.goto('/src/components/modal/test/safe-area', config); + }); + + 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'); + await ionModalDidPresent.next(); + + // Card modals on phones still extend to bottom edge + const paddingBottom = await getWrapperPaddingBottom(page); + expect(paddingBottom).toBe('34px'); + }); + + 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'); + await ionModalDidPresent.next(); + + const paddingBottom = await getWrapperPaddingBottom(page); + expect(paddingBottom).toBe('0px'); + }); + }); +}); + +// ============================================================================= +// Tablet Tests - Centered dialogs don't need safe-area, fullscreen does +// ============================================================================= + +configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('modal: safe-area - tablet'), () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize(Viewports.tablet.portrait); + await page.goto('/src/components/modal/test/safe-area', config); + }); + + 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'); + await ionModalDidPresent.next(); + + // Centered dialog on tablet - inset from edges, no padding needed + const paddingBottom = await getWrapperPaddingBottom(page); + expect(paddingBottom).toBe('0px'); + }); + + 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'); + await ionModalDidPresent.next(); + + const paddingBottom = await getWrapperPaddingBottom(page); + expect(paddingBottom).toBe('34px'); + }); + + 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'); + await ionModalDidPresent.next(); + + const paddingBottom = await getWrapperPaddingBottom(page); + expect(paddingBottom).toBe('0px'); + }); + }); +}); + +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('modal: safe-area - card modal on tablet'), () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize(Viewports.tablet.portrait); + await page.goto('/src/components/modal/test/safe-area', config); + }); + + 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'); + await ionModalDidPresent.next(); + + // Card modals on tablets are inset from all edges + const paddingBottom = await getWrapperPaddingBottom(page); + expect(paddingBottom).toBe('0px'); + }); + }); +}); + +// ============================================================================= +// Sheet Modal Tests - Always touch bottom edge +// ============================================================================= + +configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('modal: safe-area - sheet modal'), () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize(Viewports.tablet.portrait); + await page.goto('/src/components/modal/test/safe-area', config); + }); + + 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'); + await ionModalDidPresent.next(); + + // Sheet modals with footer - footer handles the safe area + const paddingBottom = await getWrapperPaddingBottom(page); + expect(paddingBottom).toBe('0px'); + }); + }); +}); diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts index aa4e0568143..22e8da58d4f 100644 --- a/core/src/components/popover/animations/ios.enter.ts +++ b/core/src/components/popover/animations/ios.enter.ts @@ -61,6 +61,8 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => top, left, bottom, + checkSafeAreaTop, + checkSafeAreaBottom, checkSafeAreaLeft, checkSafeAreaRight, arrowTop, @@ -118,15 +120,27 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => baseEl.classList.add('popover-bottom'); } - if (bottom !== undefined) { - contentEl.style.setProperty('bottom', `${bottom}px`); - } - + /** + * Safe area CSS variable adjustments. + * When the popover is positioned near an edge, we add the corresponding + * safe-area inset to ensure the popover doesn't overlap with system UI + * (status bars, home indicators, navigation bars on Android API 36+, etc.) + */ + 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}`; + } + if (checkSafeAreaBottom && bottomValue !== undefined) { + bottomValue = `${bottom}px${safeAreaBottom}`; + } if (checkSafeAreaLeft) { leftValue = `${left}px${safeAreaLeft}`; } @@ -134,7 +148,11 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => leftValue = `${left}px${safeAreaRight}`; } - contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`); + if (bottomValue !== undefined) { + contentEl.style.setProperty('bottom', `calc(${bottomValue})`); + } + + contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0))`); contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`); contentEl.style.setProperty('transform-origin', `${originY} ${originX}`); diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts index e25f745cec4..e8a1e1adc5b 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 } = calculateWindowAdjustment( + const { + originX, + originY, + top, + left, + bottom, + checkSafeAreaTop, + checkSafeAreaBottom, + checkSafeAreaLeft, + checkSafeAreaRight, + } = calculateWindowAdjustment( side, results.top, results.left, @@ -62,6 +72,34 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => results.referenceCoordinates ); + /** + * Safe area CSS variable adjustments. + * When the popover is positioned near an edge, we add the corresponding + * safe-area inset to ensure the popover doesn't overlap with system UI + * (status bars, home indicators, navigation bars on Android API 36+, etc.) + */ + 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}`; + } + 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(); const wrapperAnimation = createAnimation(); @@ -81,13 +119,13 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => contentAnimation .addElement(contentEl) .beforeStyles({ - top: `calc(${top}px + var(--offset-y, 0px))`, - left: `calc(${left}px + var(--offset-x, 0px))`, + top: `calc(${topValue} + var(--offset-y, 0px))`, + left: `calc(${leftValue} + var(--offset-x, 0px))`, 'transform-origin': `${originY} ${originX}`, }) .beforeAddWrite(() => { - if (bottom !== undefined) { - contentEl.style.setProperty('bottom', `${bottom}px`); + if (bottomValue !== undefined) { + contentEl.style.setProperty('bottom', `calc(${bottomValue})`); } }) .fromTo('transform', 'scale(0.8)', 'scale(1)'); diff --git a/core/src/components/popover/test/safe-area/index.html b/core/src/components/popover/test/safe-area/index.html new file mode 100644 index 00000000000..271d5fa02c6 --- /dev/null +++ b/core/src/components/popover/test/safe-area/index.html @@ -0,0 +1,159 @@ + + + + + Popover - Safe Area + + + + + + + + + + + + +
+
+ +
+ + + Popover - Safe Area Positioning + + + + +

Test that popovers are positioned away from unsafe areas (shown in red).

+

The popover should be moved up/down to avoid overlapping the safe-area zones.

+ + + + +

Small Popover (Center)

+

Floating popover - positioned in center, no adjustment needed

+
+ Present +
+ + + +

Large Popover

+

Tall content that may extend toward bottom safe area

+
+ Present +
+
+ + Trigger Near Bottom + + Near Bottom Right + + + + + + Option 1 + Option 2 + Option 3 + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts b/core/src/components/popover/test/safe-area/popover.e2e.ts new file mode 100644 index 00000000000..13658a256d0 --- /dev/null +++ b/core/src/components/popover/test/safe-area/popover.e2e.ts @@ -0,0 +1,76 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * Safe-area tests verify that popovers are correctly positioned + * to avoid overlapping with safe-area zones (status bars, navigation bars, etc.) + * + * This is especially important for Android API 36+ where edge-to-edge mode + * is enforced and apps can no longer opt out. + */ + +// 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); + }); + + 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'); + + // Click the trigger near the bottom of the screen + await page.click('#bottom-trigger'); + await ionPopoverDidPresent.next(); + + // Target the specific popover that was presented (the one with trigger="bottom-trigger") + 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 + // 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'); + + await page.click('#small-popover-trigger'); + await ionPopoverDidPresent.next(); + + // Target the specific popover + const popover = page.locator('ion-popover[trigger="small-popover-trigger"]'); + const popoverContent = popover.locator('.popover-content'); + + // Get the computed top and bottom styles + const topStyle = await popoverContent.evaluate((el) => el.style.top); + const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom); + + // A floating popover in the middle shouldn't have safe-area adjustments + // The top should be a simple calc without safe-area + expect(topStyle).not.toContain('var(--ion-safe-area-top'); + // The bottom should not be set for a floating popover + expect(bottomStyle).toBe(''); + }); + }); +}); diff --git a/core/src/components/popover/utils.ts b/core/src/components/popover/utils.ts index 794ebb20884..759091c01db 100644 --- a/core/src/components/popover/utils.ts +++ b/core/src/components/popover/utils.ts @@ -30,6 +30,8 @@ export interface PopoverStyles { bottom?: number; originX: string; originY: string; + checkSafeAreaTop: boolean; + checkSafeAreaBottom: boolean; checkSafeAreaLeft: boolean; checkSafeAreaRight: boolean; arrowTop: number; @@ -829,6 +831,8 @@ export const calculateWindowAdjustment = ( let bottom; let originX = contentOriginX; let originY = contentOriginY; + let checkSafeAreaTop = false; + let checkSafeAreaBottom = false; let checkSafeAreaLeft = false; let checkSafeAreaRight = false; const triggerTop = triggerCoordinates @@ -874,26 +878,57 @@ export const calculateWindowAdjustment = ( * We chose 12 here so that the popover position looks a bit nicer as * it is not right up against the edge of the screen. */ - top = Math.max(12, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1)); + top = Math.max(bodyPadding, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1)); arrowTop = top + contentHeight; originY = 'bottom'; addPopoverBottomClass = true; + /** + * If the popover is positioned near the top edge, account for safe area. + * This ensures the popover doesn't overlap with status bars or notches. + */ + if (top <= bodyPadding + safeAreaMargin) { + checkSafeAreaTop = true; + top = bodyPadding; + } + /** * If not enough room for popover to appear * above trigger, then cut it off. */ } else { bottom = bodyPadding; + /** + * When the popover is pinned to the bottom, account for safe area. + * This ensures the popover doesn't overlap with home indicators + * or navigation bars (e.g., Android API 36+ edge-to-edge). + */ + checkSafeAreaBottom = true; } } + /** + * Final check: If the popover extends into any safe-area region, + * ensure the corresponding flag is set regardless of side. + * This handles cases where a side-positioned popover (left/right) + * still needs bottom safe-area padding because it extends into that region. + */ + const popoverBottom = bottom !== undefined ? bodyHeight - bottom : top + contentHeight; + if (popoverBottom + safeAreaMargin > bodyHeight) { + checkSafeAreaBottom = true; + } + if (top < safeAreaMargin) { + checkSafeAreaTop = true; + } + return { top, left, bottom, originX, originY, + checkSafeAreaTop, + checkSafeAreaBottom, checkSafeAreaLeft, checkSafeAreaRight, arrowTop,