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..c41ade9c727 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'; @@ -74,6 +75,7 @@ export class Modal implements ComponentInterface, OverlayInterface { @State() private isSheetModal = false; private currentBreakpoint?: number; private wrapperEl?: HTMLElement; + private shadowEl?: HTMLElement; private backdropEl?: HTMLIonBackdropElement; private dragHandleEl?: HTMLButtonElement; private sortedBreakpoints?: number[]; @@ -98,10 +100,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 +286,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 +420,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 +608,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 +678,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 +714,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 +783,9 @@ export class Modal implements ComponentInterface, OverlayInterface { this.currentBreakpoint = breakpoint; this.ionBreakpointDidChange.emit({ breakpoint }); } - } + this.updateSafeAreaOverrides(); + }, + () => this.updateSafeAreaOverrides() ); this.gesture = gesture; @@ -849,6 +879,212 @@ 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; + } + + // Check if modal is fullscreen via CSS custom properties + // This applies to both phone and tablet sizes - custom modals may have + // non-fullscreen dimensions even on phones (e.g., --height: 70%) + 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 if (isTablet) { + // Centered dialog on tablet doesn't touch edges + this.zeroAllSafeAreas(); + } else { + // Non-fullscreen modal on phone - use coordinate-based detection + // to determine which edges it touches (e.g., bottom-aligned custom modals) + } + } + + /** + * 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 and shadow padding based on footer presence. + * Called initially and when footer is dynamically added/removed. + * Both elements must be styled identically to prevent visual mismatches. + */ + private updateFooterPadding() { + if (!this.wrapperEl) return; + + const hasFooter = this.el.querySelector('ion-footer') !== null; + // Apply to both wrapper and shadow to keep them in sync + const elements = [this.wrapperEl, this.shadowEl].filter(Boolean) as HTMLElement[]; + + if (hasFooter) { + elements.forEach((el) => { + el.style.removeProperty('padding-bottom'); + el.style.removeProperty('box-sizing'); + }); + } else { + elements.forEach((el) => { + el.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)'); + el.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 and shadow styles that may have been set for safe-area handling + [this.wrapperEl, this.shadowEl].forEach((el) => { + if (el) { + el.style.removeProperty('padding-bottom'); + el.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 +1197,8 @@ export class Modal implements ComponentInterface, OverlayInterface { } this.currentBreakpoint = undefined; this.animation = undefined; + // Reset safe-area state for potential re-presentation + this.resetSafeAreaState(); unlock(); @@ -1385,7 +1623,7 @@ export class Modal implements ComponentInterface, OverlayInterface { part="backdrop" /> - {mode === 'ios' && } + {mode === 'ios' && }
+ + + + Modal - Safe Area + + + + + + + + + + + +
+
+
+
+ + +
+ + + Modal - Safe Area + + + + +

Test safe-area handling in modals. Red overlays indicate safe areas (top, bottom, left, right).

+

+ Landscape simulation: Left and right safe areas are set to 44px to test devices with side + notches. +

+ + + + + 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..0dfee6a299d --- /dev/null +++ b/core/src/components/modal/test/safe-area/modal.e2e.ts @@ -0,0 +1,321 @@ +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 set in index.html: + * - Top: 44px, Bottom: 34px, Left: 44px, Right: 44px + * + * The test HTML includes red visual indicators for all safe areas to + * verify modal content doesn't overlap unsafe regions. + */ + +// 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'); + }); + }); +}); + +// Landscape viewport simulates devices with side notches or landscape orientation +const LandscapeViewport = { width: 844, height: 390 }; + +configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('modal: safe-area screenshots'), () => { + test('fullscreen modal should not overlap safe areas in landscape', async ({ page }) => { + await page.setViewportSize(LandscapeViewport); + await page.goto('/src/components/modal/test/safe-area', config); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#fullscreen-modal'); + await ionModalDidPresent.next(); + + // Red overlays show safe areas - modal content should not overlap them + await expect(page).toHaveScreenshot(screenshot('modal-safe-area-fullscreen-landscape')); + }); + + test('fullscreen modal without footer should show wrapper padding in landscape', async ({ page }) => { + await page.setViewportSize(LandscapeViewport); + await page.goto('/src/components/modal/test/safe-area', config); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#fullscreen-no-footer'); + await ionModalDidPresent.next(); + + // Without footer, wrapper padding prevents content from overlapping bottom safe area + await expect(page).toHaveScreenshot(screenshot('modal-safe-area-fullscreen-no-footer-landscape')); + }); + }); +}); + +configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('modal: safe-area screenshots - tablet'), () => { + test('centered dialog should be inset from all safe areas', async ({ page }) => { + await page.setViewportSize(Viewports.tablet.portrait); + await page.goto('/src/components/modal/test/safe-area', config); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#default-modal'); + await ionModalDidPresent.next(); + + // Centered dialog should not touch any edges or safe areas + await expect(page).toHaveScreenshot(screenshot('modal-safe-area-centered-tablet')); + }); + }); +}); + +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('modal: safe-area screenshots - card modal'), () => { + test('card modal should handle safe areas correctly in landscape', async ({ page }) => { + await page.setViewportSize(LandscapeViewport); + await page.goto('/src/components/modal/test/safe-area', config); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#card-modal'); + await ionModalDidPresent.next(); + + await expect(page).toHaveScreenshot(screenshot('modal-safe-area-card-landscape')); + }); + }); +}); diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-card-landscape-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-card-landscape-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..43f01ad4895 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-card-landscape-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-card-landscape-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-card-landscape-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..f2c499ab2c8 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-card-landscape-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-card-landscape-ios-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-card-landscape-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..2b72582b60f Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-card-landscape-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..20aef2af263 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..c8111d38e62 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-ios-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..64b5da77509 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-md-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..e35ab6e8f3a Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-md-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..1b0397762c6 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-md-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..85206f61b19 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-centered-tablet-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..0fbe8a7fb66 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..3cb8cb6eadb Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-ios-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..750385a5cc7 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-md-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..51a25bd91e4 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-md-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..63bf0eab058 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-md-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..bdcaecee47b Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-landscape-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..fcf25495d28 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..9a2cac494e2 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-ios-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..1dfa61552ae Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-md-ltr-Mobile-Chrome-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..b322b39ba08 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-md-ltr-Mobile-Firefox-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..4968b2c75bd Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-md-ltr-Mobile-Safari-linux.png b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..34039988e38 Binary files /dev/null and b/core/src/components/modal/test/safe-area/modal.e2e.ts-snapshots/modal-safe-area-fullscreen-no-footer-landscape-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts index aa4e0568143..8abca43ca5e 100644 --- a/core/src/components/popover/animations/ios.enter.ts +++ b/core/src/components/popover/animations/ios.enter.ts @@ -11,6 +11,12 @@ import { } from '../utils'; const POPOVER_IOS_BODY_PADDING = 5; +/** + * Extra margin around viewport edges for safe area detection. + * When popover is within this distance of an edge, safe area + * CSS variables will be applied to prevent overlap with system UI. + */ +const POPOVER_IOS_SAFE_AREA_MARGIN = 25; /** * iOS Popover Enter Animation @@ -53,7 +59,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => ); const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING; - const margin = size === 'cover' ? 0 : 25; + const margin = size === 'cover' ? 0 : POPOVER_IOS_SAFE_AREA_MARGIN; const { originX, @@ -61,11 +67,14 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => top, left, bottom, + checkSafeAreaTop, + checkSafeAreaBottom, checkSafeAreaLeft, checkSafeAreaRight, arrowTop, arrowLeft, addPopoverBottomClass, + isFullyConstrained, } = calculateWindowAdjustment( side, results.top, @@ -84,8 +93,37 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => arrowHeight ); + /** + * 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, 0px)'; + const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0px)'; + const safeAreaLeft = ' + var(--ion-safe-area-left, 0px)'; + const safeAreaRight = ' - var(--ion-safe-area-right, 0px)'; + + 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 arrowAnimation = createAnimation(); const contentAnimation = createAnimation(); backdropAnimation @@ -100,11 +138,42 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => // The Chromium team stated that this behavior is expected and not a bug. The element animating opacity creates a backdrop root for the backdrop-filter. // To get around this, instead of animating the wrapper, animate both the arrow and content. // https://bugs.chromium.org/p/chromium/issues/detail?id=1148826 + // TODO(FW-4376) Ensure that arrow also blurs when translucent + if (arrowEl !== null) { + arrowAnimation.addElement(arrowEl).fromTo('opacity', 0.01, 1); + } + contentAnimation - .addElement(root.querySelector('.popover-arrow')!) - .addElement(root.querySelector('.popover-content')!) + .addElement(contentEl) + .beforeAddWrite(() => { + contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0px))`); + contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0px))`); + contentEl.style.setProperty('transform-origin', `${originY} ${originX}`); + + if (bottomValue !== undefined) { + contentEl.style.setProperty('bottom', `calc(${bottomValue})`); + /** + * When both top and bottom are explicitly constrained (isFullyConstrained), + * we need to explicitly calculate the height to ensure the popover + * fits within the safe area boundaries. + * + * Using CSS calc with 100vh minus top and bottom values ensures the + * popover height respects both safe areas. We also override max-height + * to prevent it from interfering with the calculated height. + */ + if (isFullyConstrained) { + /** + * Wrap topValue and bottomValue in parentheses to ensure correct + * order of operations in the CSS calc. Without parentheses, the + * safe-area additions would have wrong signs. + */ + const heightCalc = `calc(100vh - (${topValue}) - (${bottomValue}) - var(--offset-y, 0px))`; + contentEl.style.setProperty('height', heightCalc); + contentEl.style.setProperty('max-height', heightCalc); + } + } + }) .fromTo('opacity', 0.01, 1); - // TODO(FW-4376) Ensure that arrow also blurs when translucent return baseAnimation .easing('ease') @@ -118,37 +187,21 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => baseEl.classList.add('popover-bottom'); } - if (bottom !== undefined) { - contentEl.style.setProperty('bottom', `${bottom}px`); - } - - const safeAreaLeft = ' + var(--ion-safe-area-left, 0)'; - const safeAreaRight = ' - var(--ion-safe-area-right, 0)'; - - let leftValue = `${left}px`; - - if (checkSafeAreaLeft) { - leftValue = `${left}px${safeAreaLeft}`; - } - if (checkSafeAreaRight) { - leftValue = `${left}px${safeAreaRight}`; - } - - contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`); - contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`); - contentEl.style.setProperty('transform-origin', `${originY} ${originX}`); - if (arrowEl !== null) { const didAdjustBounds = results.top !== top || results.left !== left; - const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger); + /** + * Hide the arrow when the popover is fully constrained to the viewport + * because it cannot accurately point to the trigger in this case. + */ + const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger) && !isFullyConstrained; if (showArrow) { - arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`); - arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0))`); + arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0px))`); + arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0px))`); } else { arrowEl.style.setProperty('display', 'none'); } } }) - .addAnimation([backdropAnimation, contentAnimation]); + .addAnimation([backdropAnimation, arrowAnimation, contentAnimation]); }; diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts index e25f745cec4..94fa52ddb5c 100644 --- a/core/src/components/popover/animations/md.enter.ts +++ b/core/src/components/popover/animations/md.enter.ts @@ -5,6 +5,12 @@ import type { Animation } from '../../../interface'; import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition } from '../utils'; const POPOVER_MD_BODY_PADDING = 12; +/** + * Extra margin around viewport edges for safe area detection. + * When popover is within this distance of an edge, safe area + * CSS variables will be applied to prevent overlap with system UI. + */ +const POPOVER_MD_SAFE_AREA_MARGIN = 25; /** * Md Popover Enter Animation @@ -47,7 +53,20 @@ 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 margin = size === 'cover' ? 0 : POPOVER_MD_SAFE_AREA_MARGIN; + + const { + originX, + originY, + top, + left, + bottom, + checkSafeAreaTop, + checkSafeAreaBottom, + checkSafeAreaLeft, + checkSafeAreaRight, + isFullyConstrained, + } = calculateWindowAdjustment( side, results.top, results.left, @@ -56,12 +75,40 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => bodyHeight, contentWidth, contentHeight, - 0, + margin, results.originX, results.originY, 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 +128,32 @@ 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})`); + /** + * When both top and bottom are explicitly constrained (isFullyConstrained), + * we need to explicitly calculate the height to ensure the popover + * fits within the safe area boundaries. + * + * Using CSS calc with 100vh minus top and bottom values ensures the + * popover height respects both safe areas. We also override max-height + * to prevent it from interfering with the calculated height. + */ + if (isFullyConstrained) { + /** + * Wrap topValue and bottomValue in parentheses to ensure correct + * order of operations in the CSS calc. Without parentheses, the + * safe-area additions would have wrong signs. + */ + const heightCalc = `calc(100vh - (${topValue}) - (${bottomValue}) - var(--offset-y, 0px))`; + contentEl.style.setProperty('height', heightCalc); + contentEl.style.setProperty('max-height', heightCalc); + } } }) .fromTo('transform', 'scale(0.8)', 'scale(1)'); diff --git a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Chrome-linux.png index dad77f796a1..0adb9a2e496 100644 Binary files a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Firefox-linux.png index 2a4049598b1..4f69dc59484 100644 Binary files a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Safari-linux.png index f8cddff19e9..6bfbca821df 100644 Binary files a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Chrome-linux.png index 97656deb25c..fd5df64d164 100644 Binary files a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Firefox-linux.png index acca4200b42..557bdeba2b6 100644 Binary files a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Safari-linux.png index b6bb1f4cfde..59657201efc 100644 Binary files a/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Safari-linux.png and b/core/src/components/popover/test/basic/popover.e2e.ts-snapshots/popover-basic-long-list-popover-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Chrome-linux.png index 35a308707b9..ec33022707c 100644 Binary files a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Firefox-linux.png index d09f949d1f3..5c2fee9eb13 100644 Binary files a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Safari-linux.png index d0a0d43470d..013d0bd4770 100644 Binary files a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Safari-linux.png and b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Chrome-linux.png b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Chrome-linux.png index c72c910c68c..f1ed0a237b9 100644 Binary files a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Chrome-linux.png and b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Firefox-linux.png b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Firefox-linux.png index 9a60e5f2b06..e3ff6038e8d 100644 Binary files a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Firefox-linux.png and b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Safari-linux.png b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Safari-linux.png index bb8b6d421f5..919aa5688dd 100644 Binary files a/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Safari-linux.png and b/core/src/components/popover/test/nested/popover.e2e.ts-snapshots/popover-nested-multiple-md-rtl-Mobile-Safari-linux.png differ 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..cb65329f5f1 --- /dev/null +++ b/core/src/components/popover/test/safe-area/index.html @@ -0,0 +1,180 @@ + + + + + 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/left/right to avoid overlapping the safe-area zones.

+

+ Landscape simulation: Left and right safe areas are set to 44px to test devices with side + notches. +

+ + + + +

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..2e49dc1b75e --- /dev/null +++ b/core/src/components/popover/test/safe-area/popover.e2e.ts @@ -0,0 +1,133 @@ +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. + * + * The test HTML includes safe-area values (44px top/left/right, 34px bottom) + * and red visual indicators to verify popover positioning. + */ + +// 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 small viewport to force the popover to be fully constrained. + * The large popover has 15 items (~700px), which will exceed the available + * space in this viewport, causing it to be constrained with both top and + * bottom edges near the safe areas. + * + * A 300px viewport ensures there's not enough space above OR below the + * trigger for the full popover content, triggering the fully constrained path. + */ + await page.setViewportSize({ width: 375, height: 300 }); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + // Click the large popover trigger which has enough content to extend toward the bottom + await page.click('#large-popover-trigger'); + await ionPopoverDidPresent.next(); + + // Target the specific popover that was presented + const popover = page.locator('ion-popover[trigger="large-popover-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(''); + }); + }); +}); + +// Landscape viewport simulates devices with side notches or landscape orientation +const LandscapeViewport = { width: 844, height: 390 }; + +configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('popover: safe-area screenshots'), () => { + test('popover near bottom should avoid bottom safe area', async ({ page }) => { + await page.setViewportSize(LandscapeViewport); + await page.goto('/src/components/popover/test/safe-area', config); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + await page.click('#bottom-trigger'); + await ionPopoverDidPresent.next(); + + // Red overlays show safe areas - popover should be positioned to avoid them + await expect(page).toHaveScreenshot(screenshot('popover-safe-area-bottom-landscape')); + }); + + test('popover near bottom right should avoid right safe area', async ({ page }) => { + await page.setViewportSize(LandscapeViewport); + await page.goto('/src/components/popover/test/safe-area', config); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + await page.click('#near-bottom-trigger'); + await ionPopoverDidPresent.next(); + + // Popover triggered from near-right edge should account for right safe area + await expect(page).toHaveScreenshot(screenshot('popover-safe-area-right-landscape')); + }); + + test('large popover should avoid all safe areas', async ({ page }) => { + await page.setViewportSize(LandscapeViewport); + await page.goto('/src/components/popover/test/safe-area', config); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + await page.click('#large-popover-trigger'); + await ionPopoverDidPresent.next(); + + // Large popover may extend toward edges - should respect safe areas + await expect(page).toHaveScreenshot(screenshot('popover-safe-area-large-landscape')); + }); + }); +}); diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..7e561265dff Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..d3b210a8012 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..276308201f0 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..379045a9428 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..e0197c8f23d Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..e9caad6e513 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-landscape-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..666b18bd3d2 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..fcfa7fe32de Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..025e994f91d Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..5a605da5676 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..3022119287f Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..ee41c84325a Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-landscape-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..666b18bd3d2 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..163711371c7 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..eca032b52db Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..5a605da5676 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..3022119287f Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..ee41c84325a Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-landscape-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/popover/utils.ts b/core/src/components/popover/utils.ts index 794ebb20884..b6a25498976 100644 --- a/core/src/components/popover/utils.ts +++ b/core/src/components/popover/utils.ts @@ -30,11 +30,20 @@ export interface PopoverStyles { bottom?: number; originX: string; originY: string; + checkSafeAreaTop: boolean; + checkSafeAreaBottom: boolean; checkSafeAreaLeft: boolean; checkSafeAreaRight: boolean; arrowTop: number; arrowLeft: number; addPopoverBottomClass: boolean; + /** + * When true, the popover content was too tall to fit above or below + * the trigger, so it was constrained to the full viewport height. + * In this case, the arrow should be hidden as it cannot accurately + * point to the trigger. + */ + isFullyConstrained: boolean; } /** @@ -829,8 +838,11 @@ export const calculateWindowAdjustment = ( let bottom; let originX = contentOriginX; let originY = contentOriginY; + let checkSafeAreaTop = false; + let checkSafeAreaBottom = false; let checkSafeAreaLeft = false; let checkSafeAreaRight = false; + let isFullyConstrained = false; const triggerTop = triggerCoordinates ? triggerCoordinates.top + triggerCoordinates.height : bodyHeight / 2 - contentHeight / 2; @@ -841,20 +853,29 @@ export const calculateWindowAdjustment = ( * Adjust popover so it does not * go off the left of the screen. */ - if (left < bodyPadding + safeAreaMargin) { + if (left < bodyPadding) { left = bodyPadding; - checkSafeAreaLeft = true; originX = 'left'; /** * Adjust popover so it does not * go off the right of the screen. */ - } else if (contentWidth + bodyPadding + left + safeAreaMargin > bodyWidth) { - checkSafeAreaRight = true; + } else if (contentWidth + bodyPadding + left > bodyWidth) { left = bodyWidth - contentWidth - bodyPadding; originX = 'right'; } + /** + * After position adjustment, check if popover is near edges + * and needs safe-area CSS variable adjustments. + */ + if (left <= safeAreaMargin) { + checkSafeAreaLeft = true; + } + if (left + contentWidth >= bodyWidth - safeAreaMargin) { + checkSafeAreaRight = true; + } + /** * Adjust popover so it does not * go off the top of the screen. @@ -863,7 +884,19 @@ export const calculateWindowAdjustment = ( * margins. */ if (triggerTop + triggerHeight + contentHeight > bodyHeight && (side === 'top' || side === 'bottom')) { - if (triggerTop - contentHeight > 0) { + /** + * Calculate available space above and below, accounting for safe areas. + * This ensures we flip to whichever side has more usable space. + */ + const spaceAbove = (triggerCoordinates?.top ?? triggerTop) - bodyPadding - safeAreaMargin; + const spaceBelow = bodyHeight - triggerTop - triggerHeight - bodyPadding - safeAreaMargin; + + /** + * Flip above if: + * 1. Content fits entirely above the trigger, OR + * 2. There's more usable space above than below (accounting for safe areas) + */ + if (triggerTop - contentHeight > 0 || spaceAbove > spaceBelow) { /** * While we strive to align the popover with the trigger * on smaller screens this is not always possible. As a result, @@ -874,31 +907,90 @@ 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 not enough room for popover to appear - * above trigger, then cut it off. + * 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; + } + + /** + * After flipping above, check if popover will likely overflow the viewport. + * This can happen when the popover is taller than the available space. + * + * When checkSafeAreaTop is true, the CSS will add safe-area-top to the + * top position, pushing the popover down. Since we don't know the exact + * CSS safe-area value at runtime, we use a conservative threshold that + * accounts for typical safe-area sizes (usually 40-50px). By checking + * against (safeAreaMargin * 2), we ensure that: + * 1. Any popover close to the viewport boundary gets constrained + * 2. The safe-area CSS variables have room to be applied without overflow + */ + if (checkSafeAreaTop && top + contentHeight > bodyHeight - safeAreaMargin * 2 - bodyPadding) { + bottom = bodyPadding; + checkSafeAreaBottom = true; + isFullyConstrained = true; + } + + /** + * If not enough room for popover to appear above trigger + * (i.e., content is taller than space above), then constrain + * the popover to fill the entire viewport from top to bottom. */ } else { + top = bodyPadding; bottom = bodyPadding; + checkSafeAreaTop = true; + checkSafeAreaBottom = true; + isFullyConstrained = true; } } + /** + * Check if popover is near edges and needs safe-area adjustments. + * When the popover extends into the safe-area zone, set a bottom constraint + * to push it up and out of the unsafe area. This is essential for + * edge-to-edge displays on Android API 36+ and iOS devices with home indicators. + */ + const popoverBottom = bottom !== undefined ? bodyHeight - bottom : top + contentHeight; + if (popoverBottom > bodyHeight - safeAreaMargin && bottom === undefined) { + checkSafeAreaBottom = true; + /** + * Set a bottom constraint to push the popover up out of the safe-area zone. + * The animation will add the safe-area CSS variable to this value. + * + * We also set isFullyConstrained so that height: unset is applied, + * allowing the bottom constraint to actually take effect (otherwise + * the explicit height would override the bottom constraint). + */ + bottom = bodyPadding; + isFullyConstrained = true; + } + if (top < safeAreaMargin) { + checkSafeAreaTop = true; + } + return { top, left, bottom, originX, originY, + checkSafeAreaTop, + checkSafeAreaBottom, checkSafeAreaLeft, checkSafeAreaRight, arrowTop, arrowLeft, addPopoverBottomClass, + isFullyConstrained, }; }; diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png index 058e9eb36b8..7ea7ccc3ac9 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png index f6dda21ddea..ced1ed72e4f 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png index 5a57da67318..40b3f6aefa1 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png index 48f5106e004..b9cde6a41f7 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png index e13afdfc587..24cb2735029 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Safari-linux.png b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Safari-linux.png index e6e9c4fdb8c..7211b7dba93 100644 Binary files a/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Safari-linux.png and b/core/src/components/select/test/basic/select.e2e.ts-snapshots/select-basic-popover-scroll-to-selected-md-ltr-Mobile-Safari-linux.png differ