diff --git a/core/src/components/datetime/test/custom/datetime.e2e.ts-snapshots/datetime-custom-time-button-active-ios-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/custom/datetime.e2e.ts-snapshots/datetime-custom-time-button-active-ios-ltr-Mobile-Safari-linux.png index 7c7e296086a..f4bc6529a2b 100644 Binary files a/core/src/components/datetime/test/custom/datetime.e2e.ts-snapshots/datetime-custom-time-button-active-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/datetime/test/custom/datetime.e2e.ts-snapshots/datetime-custom-time-button-active-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/datetime/test/custom/datetime.e2e.ts-snapshots/datetime-custom-time-picker-ios-ltr-Mobile-Safari-linux.png b/core/src/components/datetime/test/custom/datetime.e2e.ts-snapshots/datetime-custom-time-picker-ios-ltr-Mobile-Safari-linux.png index c779ad81e99..3516195baf6 100644 Binary files a/core/src/components/datetime/test/custom/datetime.e2e.ts-snapshots/datetime-custom-time-picker-ios-ltr-Mobile-Safari-linux.png and b/core/src/components/datetime/test/custom/datetime.e2e.ts-snapshots/datetime-custom-time-picker-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/modal/modal.scss b/core/src/components/modal/modal.scss index 7c5ec7916fe..160468f5792 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; } } @@ -156,9 +152,14 @@ ion-backdrop { /** * Ensure that the sheet modal does not * completely cover the content. + * + * --ion-modal-offset-top is an internal property set by modal.tsx + * with the resolved root safe-area-top pixel value. This decouples + * the height calculation from --ion-safe-area-top (which is zeroed + * for sheet modals to prevent header double-padding). */ :host(.modal-sheet) { - --height: calc(100% - (var(--ion-safe-area-top) + 10px)); + --height: calc(100% - (var(--ion-modal-offset-top, 0px) + 10px)); } :host(.modal-sheet) .modal-wrapper, diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index a96d59c8e9f..dddd7bac808 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -44,6 +44,14 @@ import type { MoveSheetToBreakpointOptions } from './gestures/sheet'; import { createSheetGesture } from './gestures/sheet'; import { createSwipeToCloseGesture, SwipeToCloseDefaults } from './gestures/swipe-to-close'; import type { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from './modal-interface'; +import { + getInitialSafeAreaConfig, + getPositionBasedSafeAreaConfig, + applySafeAreaOverrides, + clearSafeAreaOverrides, + getRootSafeAreaTop, + type ModalSafeAreaContext, +} from './safe-area-utils'; import { setCardStatusBarDark, setCardStatusBarDefault } from './utils'; // TODO(FW-2832): types @@ -276,14 +284,35 @@ export class Modal implements ComponentInterface, OverlayInterface { @Listen('resize', { target: 'window' }) onWindowResize() { - // Only handle resize for iOS card modals when no custom animations are provided - if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) { - return; - } + if (!this.presented) return; clearTimeout(this.resizeTimeout); this.resizeTimeout = setTimeout(() => { - this.handleViewTransition(); + const context = this.getSafeAreaContext(); + + // iOS card modals: handle portrait/landscape view transitions + if (context.isCardModal && !this.enterAnimation && !this.leaveAnimation) { + this.handleViewTransition(); + } + + // Sheet modals: re-compute the internal offset property since safe-area + // values may change on device rotation (e.g., portrait notch vs landscape). + if (context.isSheetModal) { + this.updateSheetOffsetTop(); + } + + // Regular (non-sheet, non-card) modals: update safe-area overrides + // since the viewport may have crossed the centered-dialog breakpoint. + if (!context.isSheetModal && !context.isCardModal) { + this.updateSafeAreaOverrides(); + + // Re-evaluate fullscreen safe-area padding: clear first, then re-apply + if (this.wrapperEl) { + this.wrapperEl.style.removeProperty('height'); + this.wrapperEl.style.removeProperty('padding-bottom'); + } + this.applyFullscreenSafeArea(); + } }, 50); // Debounce to avoid excessive calls during active resizing } @@ -406,6 +435,9 @@ export class Modal implements ComponentInterface, OverlayInterface { this.triggerController.removeClickListener(); this.cleanupViewTransitionListener(); this.cleanupParentRemovalObserver(); + // Also called in dismiss() — intentional dual cleanup covers both + // dismiss-then-remove and direct DOM removal without dismiss. + this.cleanupSafeAreaOverrides(); } componentWillLoad() { @@ -594,6 +626,13 @@ export class Modal implements ComponentInterface, OverlayInterface { writeTask(() => this.el.classList.add('show-modal')); + // Recalculate isSheetModal before safe-area setup because framework + // bindings (e.g., Angular) may not have been applied when componentWillLoad ran. + this.isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined; + + // Set initial safe-area overrides before animation + this.setInitialSafeAreaOverrides(); + const hasCardModal = presentingElement !== undefined; /** @@ -614,6 +653,12 @@ export class Modal implements ComponentInterface, OverlayInterface { expandToScroll: this.expandToScroll, }); + // Update safe-area based on actual position after animation + this.updateSafeAreaOverrides(); + + // Apply fullscreen safe-area padding if needed + this.applyFullscreenSafeArea(); + /* tslint:disable-next-line */ if (typeof window !== 'undefined') { /** @@ -646,14 +691,7 @@ export class Modal implements ComponentInterface, OverlayInterface { window.addEventListener(KEYBOARD_DID_OPEN, this.keyboardOpenCallback); } - /** - * Recalculate isSheetModal because framework bindings (e.g., Angular) - * may not have been applied when componentWillLoad ran. - */ - const isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined; - this.isSheetModal = isSheetModal; - - if (isSheetModal) { + if (this.isSheetModal) { this.initSheetGesture(); } else if (hasCardModal) { this.initSwipeToClose(); @@ -885,6 +923,10 @@ export class Modal implements ComponentInterface, OverlayInterface { return false; } + // Cancel any pending resize timeout to prevent stale updates during dismiss + clearTimeout(this.resizeTimeout); + this.resizeTimeout = undefined; + /** * Because the canDismiss check below is async, * we need to claim a lock before the check happens, @@ -956,6 +998,7 @@ export class Modal implements ComponentInterface, OverlayInterface { } this.cleanupViewTransitionListener(); this.cleanupParentRemovalObserver(); + this.cleanupSafeAreaOverrides(); this.cleanupChildRoutePassthrough(); } @@ -1166,6 +1209,10 @@ export class Modal implements ComponentInterface, OverlayInterface { transitionAnimation.play().then(() => { this.viewTransitionAnimation = undefined; + // Wait for a layout pass after the transition so getBoundingClientRect() + // in getPositionBasedSafeAreaConfig() reflects the new dimensions. + raf(() => this.updateSafeAreaOverrides()); + // After orientation transition, recreate the swipe-to-close gesture // with updated animation that reflects the new presenting element state this.reinitSwipeToClose(); @@ -1335,6 +1382,130 @@ export class Modal implements ComponentInterface, OverlayInterface { this.parentRemovalObserver = undefined; } + /** + * Creates the context object for safe-area utilities. + */ + private getSafeAreaContext(): ModalSafeAreaContext { + return { + isSheetModal: this.isSheetModal, + isCardModal: this.presentingElement !== undefined && getIonMode(this) === 'ios', + presentingElement: this.presentingElement, + breakpoints: this.breakpoints, + currentBreakpoint: this.currentBreakpoint, + }; + } + + /** + * Sets initial safe-area overrides before modal animation. + * Called in present() before animation starts. + * + * For sheet modals, the SCSS --height formula uses --ion-modal-offset-top + * (an internal property) instead of --ion-safe-area-top. We resolve the + * root safe-area-top to pixels and set --ion-modal-offset-top, decoupling + * the height calculation from --ion-safe-area-top (which is zeroed for + * sheets to prevent header content from getting double-offset padding). + */ + private setInitialSafeAreaOverrides(): void { + const context = this.getSafeAreaContext(); + const safeAreaConfig = getInitialSafeAreaConfig(context); + applySafeAreaOverrides(this.el, safeAreaConfig); + + // Set the internal offset property with the resolved root safe-area-top value + if (context.isSheetModal) { + this.updateSheetOffsetTop(); + } + } + + /** + * Resolves the current root --ion-safe-area-top value and sets the + * internal --ion-modal-offset-top property on the host element. + * Called on present and on resize (e.g., device rotation changes safe-area). + */ + private updateSheetOffsetTop(): void { + const safeAreaTop = getRootSafeAreaTop(); + this.el.style.setProperty('--ion-modal-offset-top', `${safeAreaTop}px`); + } + + /** + * Updates safe-area overrides during dynamic state changes. + * Called after animations, during gestures, and on orientation changes. + */ + private updateSafeAreaOverrides(): void { + const { wrapperEl, el } = this; + const context = this.getSafeAreaContext(); + + // Sheet modals: safe-area is fully determined at presentation time + // (top is always 0px, height is frozen). Nothing to update. + if (context.isSheetModal) return; + + // Card modals have fixed safe-area requirements set by initial prediction. + if (context.isCardModal) return; + + // wrapperEl is required for position-based detection below + if (!wrapperEl) return; + + // Regular modals: use position-based detection to correctly handle both + // fullscreen modals and centered dialogs with custom dimensions. + const safeAreaConfig = getPositionBasedSafeAreaConfig(wrapperEl); + applySafeAreaOverrides(el, safeAreaConfig); + } + + /** + * Applies padding-bottom to fullscreen modal wrapper to prevent + * content from overlapping system navigation bar. + */ + private applyFullscreenSafeArea(): void { + const { wrapperEl, el } = this; + if (!wrapperEl) return; + + const context = this.getSafeAreaContext(); + if (context.isSheetModal || context.isCardModal) return; + + // Check for standard Ionic layout children (ion-content, ion-footer), + // searching one level deep for wrapped components (e.g., + // ...). + // Note: uses a manual loop instead of querySelector(':scope > ...') because + // Stencil's mock-doc (used in spec tests) does not support :scope. + let hasContent = false; + let hasFooter = false; + for (const child of Array.from(el.children)) { + if (child.tagName === 'ION-CONTENT') hasContent = true; + if (child.tagName === 'ION-FOOTER') hasFooter = true; + for (const grandchild of Array.from(child.children)) { + if (grandchild.tagName === 'ION-CONTENT') hasContent = true; + if (grandchild.tagName === 'ION-FOOTER') hasFooter = true; + } + } + + // Only apply wrapper padding for standard Ionic layouts (has ion-content + // but no ion-footer). Custom modals with raw HTML are fully + // developer-controlled and should not be modified. + if (!hasContent || hasFooter) return; + + // Reduce wrapper height by safe-area and add equivalent padding so the + // total visual size stays the same but the flex content area shrinks. + // Using height + padding instead of box-sizing: border-box avoids + // breaking custom modals that set --border-width (border-box would + // include the border inside the height, changing the layout). + wrapperEl.style.setProperty('height', 'calc(var(--height) - var(--ion-safe-area-bottom, 0px))'); + wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)'); + } + + /** + * Clears all safe-area overrides and padding from wrapper. + */ + private cleanupSafeAreaOverrides(): void { + clearSafeAreaOverrides(this.el); + + // Remove internal sheet offset property + this.el.style.removeProperty('--ion-modal-offset-top'); + + if (this.wrapperEl) { + this.wrapperEl.style.removeProperty('height'); + this.wrapperEl.style.removeProperty('padding-bottom'); + } + } + render() { const { handle, diff --git a/core/src/components/modal/safe-area-utils.ts b/core/src/components/modal/safe-area-utils.ts new file mode 100644 index 00000000000..a13bf1770a0 --- /dev/null +++ b/core/src/components/modal/safe-area-utils.ts @@ -0,0 +1,201 @@ +import { win } from '@utils/browser'; +import { raf } from '@utils/helpers'; + +type SafeAreaValue = '0px' | 'inherit'; + +/** + * Configuration for safe-area CSS custom properties. + * Each direction can inherit the root safe-area value or be zeroed out. + */ +export interface SafeAreaConfig { + top: SafeAreaValue; + bottom: SafeAreaValue; + left: SafeAreaValue; + right: SafeAreaValue; +} + +/** + * Context information about the modal used to determine safe-area behavior. + */ +export interface ModalSafeAreaContext { + isSheetModal: boolean; + isCardModal: boolean; + presentingElement?: HTMLElement; + breakpoints?: number[]; + currentBreakpoint?: number; +} + +/** + * These thresholds match the SCSS media query breakpoints in modal.vars.scss + * that trigger the centered dialog layout (non-fullscreen modal). + * + * SCSS defines two height breakpoints: $modal-inset-min-height-small (600px) + * and $modal-inset-min-height-large (768px). We use the smaller one because + * that's the threshold where the modal transitions from fullscreen to centered + * dialog — the larger breakpoint only increases the dialog's height. + */ +const MODAL_INSET_MIN_WIDTH = 768; +const MODAL_INSET_MIN_HEIGHT = 600; +const EDGE_THRESHOLD = 5; + +/** + * Cache for resolved root safe-area-top value, invalidated once per frame. + */ +let cachedRootSafeAreaTop: number | null = null; +let cacheInvalidationScheduled = false; + +/** + * Determines if the current viewport meets the CSS media query conditions + * that cause regular modals to render as centered dialogs instead of fullscreen. + * Matches: @media (min-width: 768px) and (min-height: 600px) + */ +const isCenteredDialogViewport = (): boolean => { + if (!win) return false; + return win.matchMedia(`(min-width: ${MODAL_INSET_MIN_WIDTH}px) and (min-height: ${MODAL_INSET_MIN_HEIGHT}px)`) + .matches; +}; + +/** + * Resolves the current root --ion-safe-area-top value to pixels. + * Uses a temporary element because getComputedStyle on :root returns + * the declared value of custom properties (e.g. "env(safe-area-inset-top)") + * rather than a resolved number. + * + * Results are cached for the current frame to avoid repeated reflows. + */ +export const getRootSafeAreaTop = (): number => { + if (cachedRootSafeAreaTop !== null) { + return cachedRootSafeAreaTop; + } + + const doc = win?.document; + if (!doc?.body) { + return 0; + } + + const el = doc.createElement('div'); + el.style.cssText = + 'position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;' + 'padding-top:var(--ion-safe-area-top,0px);'; + doc.body.appendChild(el); + const value = parseFloat(getComputedStyle(el).paddingTop) || 0; + el.remove(); + + cachedRootSafeAreaTop = value; + if (!cacheInvalidationScheduled) { + cacheInvalidationScheduled = true; + raf(() => { + cachedRootSafeAreaTop = null; + cacheInvalidationScheduled = false; + }); + } + + return value; +}; + +/** + * Returns the initial safe-area configuration based on modal type. + * This is called before animation starts and uses configuration-based prediction. + * + * @param context - Modal context information + * @returns SafeAreaConfig with initial safe-area values + */ +export const getInitialSafeAreaConfig = (context: ModalSafeAreaContext): SafeAreaConfig => { + const { isSheetModal, isCardModal } = context; + + // Sheet modals always zero top safe-area. The sheet height offset from the + // top edge is handled by --ion-modal-offset-top (set in modal.tsx) using + // the resolved root value, so --ion-safe-area-top is never needed for + // height calculation. Keeping it at 0px prevents header content from + // getting double-offset padding. + if (isSheetModal) { + return { + top: '0px', + bottom: 'inherit', + left: '0px', + right: '0px', + }; + } + + // Card modals need safe-area for height calculation. + // Note: isCardModal is already gated on mode === 'ios' by the caller. + if (isCardModal) { + return { + top: 'inherit', + bottom: 'inherit', + left: '0px', + right: '0px', + }; + } + + // On viewports that meet the centered dialog media query breakpoints, + // regular modals render as centered dialogs (not fullscreen), so they + // don't touch any screen edges and don't need safe-area insets. + if (isCenteredDialogViewport()) { + return { + top: '0px', + bottom: '0px', + left: '0px', + right: '0px', + }; + } + + // Fullscreen modals on phone - inherit all safe areas + return { + top: 'inherit', + bottom: 'inherit', + left: 'inherit', + right: 'inherit', + }; +}; + +/** + * Returns safe-area configuration based on actual modal position. + * Detects which edges the modal overlaps with and only applies safe-area to those edges. + * + * Note: On Android edge-to-edge (API 36+), getBoundingClientRect() may report + * inconsistent values. Sheet and card modals avoid this by using configuration-based + * prediction instead. Regular modals use coordinate detection which works reliably + * on web and iOS; Android edge-to-edge may need a configuration-based fallback + * once a reliable detection mechanism is available. + * + * @param wrapperEl - The modal wrapper element to measure + * @returns SafeAreaConfig based on position + */ +export const getPositionBasedSafeAreaConfig = (wrapperEl: HTMLElement): SafeAreaConfig => { + const rect = wrapperEl.getBoundingClientRect(); + const vh = win?.innerHeight ?? 0; + const vw = win?.innerWidth ?? 0; + + // Only apply safe-area to sides where modal overlaps with screen edge + return { + top: rect.top <= EDGE_THRESHOLD ? 'inherit' : '0px', + bottom: rect.bottom >= vh - EDGE_THRESHOLD ? 'inherit' : '0px', + left: rect.left <= EDGE_THRESHOLD ? 'inherit' : '0px', + right: rect.right >= vw - EDGE_THRESHOLD ? 'inherit' : '0px', + }; +}; + +/** + * Applies safe-area CSS custom property overrides to the modal host element. + * + * @param hostEl - The modal host element (ion-modal) + * @param config - Safe-area configuration to apply + */ +export const applySafeAreaOverrides = (hostEl: HTMLElement, config: SafeAreaConfig): void => { + hostEl.style.setProperty('--ion-safe-area-top', config.top); + hostEl.style.setProperty('--ion-safe-area-bottom', config.bottom); + hostEl.style.setProperty('--ion-safe-area-left', config.left); + hostEl.style.setProperty('--ion-safe-area-right', config.right); +}; + +/** + * Clears safe-area CSS custom property overrides from the modal host element. + * + * @param hostEl - The modal host element (ion-modal) + */ +export const clearSafeAreaOverrides = (hostEl: HTMLElement): void => { + hostEl.style.removeProperty('--ion-safe-area-top'); + hostEl.style.removeProperty('--ion-safe-area-bottom'); + hostEl.style.removeProperty('--ion-safe-area-left'); + hostEl.style.removeProperty('--ion-safe-area-right'); +}; 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..dadf47ff25b --- /dev/null +++ b/core/src/components/modal/test/safe-area/index.html @@ -0,0 +1,249 @@ + + + + + Modal - Safe Area + + + + + + + + + + + +
+
+ + +
+ + + Modal - Safe Area + + + + +

Fullscreen Modals

+ + + +

Sheet Modals

+ + +

Card Modals (iOS)

+ + +

Centered Dialog (Tablet)

+ + +

Diagnostic Info

+
+

Window Width:

+

Window Height:

+

Is Tablet:

+
+
+
+
+ + + + 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..af2a58c7699 --- /dev/null +++ b/core/src/components/modal/test/safe-area/modal.e2e.ts @@ -0,0 +1,370 @@ +import { expect } from '@playwright/test'; +import { configs, test, Viewports } from '@utils/test/playwright'; + +/** + * These tests verify that safe-area CSS custom properties are correctly + * applied to modals based on their type and position. + * + * Safe-area handling is position-based and not affected by text direction. + * Testing only LTR to avoid redundant test runs. + */ + +/** + * The test page (index.html) sets these root safe-area values. + * Keep in sync with the :root block in test/safe-area/index.html. + */ +const TEST_SAFE_AREA_TOP = '47px'; +configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('modal: safe-area handling'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/components/modal/test/safe-area', config); + }); + + test('fullscreen modal should inherit all safe-area values on phone', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30900', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#fullscreen-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + + // On phone viewport, fullscreen modal should inherit safe-area values + const safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-top'); + }); + const safeAreaBottom = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-bottom'); + }); + + expect(safeAreaTop).toBe('inherit'); + expect(safeAreaBottom).toBe('inherit'); + }); + + test('regular modal should have safe-area zeroed on tablet (centered dialog)', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30900', + }); + + await page.setViewportSize(Viewports.tablet.portrait); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#fullscreen-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + + // On tablet viewport, the CSS media query renders regular modals as + // centered dialogs (600x500). Since they don't touch screen edges, + // safe-area values should be zeroed out. + const safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-top'); + }); + const safeAreaBottom = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-bottom'); + }); + + expect(safeAreaTop).toBe('0px'); + expect(safeAreaBottom).toBe('0px'); + }); + + test('sheet modal should only use bottom safe-area', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30900', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#sheet-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + + // Sheet modals should have top safe-area zeroed (doesn't touch top edge) + // but bottom safe-area inherited (touches bottom edge) + const safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-top'); + }); + const safeAreaBottom = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-bottom'); + }); + + expect(safeAreaTop).toBe('0px'); + expect(safeAreaBottom).toBe('inherit'); + }); + + test('fullscreen modal without footer should have wrapper padding-bottom', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30900', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#fullscreen-modal-no-footer'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + + // When no footer is present, the wrapper should have reduced height + // and padding-bottom to prevent content from overlapping the system + // navigation bar, without changing box-sizing (which would break + // custom modals with --border-width). + const wrapper = modal.locator('.modal-wrapper'); + const paddingBottom = await wrapper.evaluate((el: HTMLElement) => { + return el.style.getPropertyValue('padding-bottom'); + }); + const height = await wrapper.evaluate((el: HTMLElement) => { + return el.style.getPropertyValue('height'); + }); + + expect(paddingBottom).toBe('var(--ion-safe-area-bottom, 0px)'); + expect(height).toBe('calc(var(--height) - var(--ion-safe-area-bottom, 0px))'); + }); + + test('sheet modal at breakpoint 1 should keep top safe-area zeroed', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30900', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#sheet-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + + // Initially at breakpoint 0.5 — top safe-area should be zeroed + let safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-top'); + }); + expect(safeAreaTop).toBe('0px'); + + // Move to breakpoint 1 (fully expanded) via the public method + const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange'); + await modal.evaluate((el: HTMLIonModalElement) => { + el.setCurrentBreakpoint(1); + }); + await ionBreakpointDidChange.next(); + + // At breakpoint 1, top safe-area should still be 0px because the + // sheet height is frozen with the resolved root value. This prevents + // header content from getting double-offset padding. + safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-top'); + }); + expect(safeAreaTop).toBe('0px'); + }); + + test('sheet modal should have --ion-modal-offset-top set with resolved safe-area value', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30900', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#sheet-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + + // The internal --ion-modal-offset-top property should be set to the + // resolved root --ion-safe-area-top value. The SCSS --height formula + // uses this instead of --ion-safe-area-top directly. + const offsetTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-modal-offset-top'); + }); + expect(offsetTop).toBe(TEST_SAFE_AREA_TOP); + }); + + test('fullscreen modal safe-area should update on resize from phone to tablet', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30900', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#fullscreen-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + + // On phone viewport, modal should inherit safe-area + const safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-top'); + }); + expect(safeAreaTop).toBe('inherit'); + + // Resize to tablet viewport (centered dialog breakpoint) + await page.setViewportSize(Viewports.tablet.portrait); + + // Poll until the debounced resize handler updates safe-area overrides + await expect + .poll(async () => { + return modal.evaluate((el: HTMLIonModalElement) => el.style.getPropertyValue('--ion-safe-area-top')); + }) + .toBe('0px'); + + const safeAreaBottom = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-bottom'); + }); + + expect(safeAreaBottom).toBe('0px'); + }); + + test('centered dialog should have all safe-area values zeroed on tablet', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30900', + }); + + await page.setViewportSize(Viewports.tablet.portrait); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#centered-dialog'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + + // Centered dialogs don't touch any edge, so all safe-areas should be zeroed + const safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-top'); + }); + const safeAreaBottom = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-bottom'); + }); + + expect(safeAreaTop).toBe('0px'); + expect(safeAreaBottom).toBe('0px'); + }); + + test('safe-area overrides should be cleared on dismiss', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30900', + }); + + // Present modal programmatically so we control the lifecycle + // (the HTML page's presenters call modal.remove() after dismiss, + // which races with our post-dismiss evaluation) + await page.evaluate(async () => { + const modal = document.createElement('ion-modal'); + modal.component = document.createElement('div'); + document.body.appendChild(modal); + await modal.present(); + }); + + const modal = page.locator('ion-modal'); + + // Verify overrides are set + let safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-top'); + }); + expect(safeAreaTop).not.toBe(''); + + // Dismiss the modal but don't remove the element + await modal.evaluate(async (el: HTMLIonModalElement) => { + await el.dismiss(); + }); + + // Verify overrides are cleared after dismiss + safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-top'); + }); + expect(safeAreaTop).toBe(''); + + // Clean up + await modal.evaluate((el: HTMLIonModalElement) => el.remove()); + }); + }); +}); + +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('modal: card modal safe-area (iOS only)'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/components/modal/test/safe-area', config); + }); + + test('card modal should inherit top and bottom safe-area', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30900', + }); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#card-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + + // Card modals need top safe-area for height calculation + const safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-top'); + }); + const safeAreaBottom = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-bottom'); + }); + const safeAreaLeft = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-left'); + }); + const safeAreaRight = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-right'); + }); + + expect(safeAreaTop).toBe('inherit'); + expect(safeAreaBottom).toBe('inherit'); + expect(safeAreaLeft).toBe('0px'); + expect(safeAreaRight).toBe('0px'); + }); + + test('card modal on tablet should still inherit safe-area values', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30900', + }); + + await page.setViewportSize(Viewports.tablet.portrait); + + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + + await page.click('#card-modal'); + await ionModalDidPresent.next(); + + const modal = page.locator('ion-modal'); + + // Card modals use safe-area values in CSS calculations regardless of viewport, + // so they should inherit even on tablet where regular modals become centered dialogs. + const safeAreaTop = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-top'); + }); + const safeAreaBottom = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-bottom'); + }); + const safeAreaLeft = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-left'); + }); + const safeAreaRight = await modal.evaluate((el: HTMLIonModalElement) => { + return el.style.getPropertyValue('--ion-safe-area-right'); + }); + + expect(safeAreaTop).toBe('inherit'); + expect(safeAreaBottom).toBe('inherit'); + expect(safeAreaLeft).toBe('0px'); + expect(safeAreaRight).toBe('0px'); + }); + }); +}); diff --git a/core/src/components/popover/animations/ios.enter.ts b/core/src/components/popover/animations/ios.enter.ts index aa4e0568143..02a078a2f71 100644 --- a/core/src/components/popover/animations/ios.enter.ts +++ b/core/src/components/popover/animations/ios.enter.ts @@ -7,11 +7,22 @@ import { getArrowDimensions, getPopoverDimensions, getPopoverPosition, + getSafeAreaInsets, shouldShowArrow, } from '../utils'; const POPOVER_IOS_BODY_PADDING = 5; +/** + * Minimum edge margin for iOS popovers ensures visual spacing from screen + * edges on devices without safe areas (e.g., older iPhones without notches). + * Previously this was a hardcoded `safeAreaMargin = 25` that served dual + * purpose: safe-area avoidance AND visual spacing. Now that actual safe-area + * insets are read dynamically, this floor preserves the visual spacing when + * safe-area values are 0. + */ +const POPOVER_IOS_MIN_EDGE_MARGIN = 25; + /** * iOS Popover Enter Animation */ @@ -53,7 +64,16 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => ); const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING; - const margin = size === 'cover' ? 0 : 25; + const rawSafeArea = getSafeAreaInsets(doc as Document); + const safeArea = + size === 'cover' + ? { top: 0, bottom: 0, left: 0, right: 0 } + : { + top: Math.max(rawSafeArea.top, POPOVER_IOS_MIN_EDGE_MARGIN), + bottom: Math.max(rawSafeArea.bottom, POPOVER_IOS_MIN_EDGE_MARGIN), + left: Math.max(rawSafeArea.left, POPOVER_IOS_MIN_EDGE_MARGIN), + right: Math.max(rawSafeArea.right, POPOVER_IOS_MIN_EDGE_MARGIN), + }; const { originX, @@ -63,9 +83,12 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => bottom, checkSafeAreaLeft, checkSafeAreaRight, + checkSafeAreaTop, + checkSafeAreaBottom, arrowTop, arrowLeft, addPopoverBottomClass, + hideArrow, } = calculateWindowAdjustment( side, results.top, @@ -75,7 +98,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => bodyHeight, contentWidth, contentHeight, - margin, + safeArea, results.originX, results.originY, results.referenceCoordinates, @@ -119,11 +142,15 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => } if (bottom !== undefined) { - contentEl.style.setProperty('bottom', `${bottom}px`); + let bottomValue = `${bottom}px`; + if (checkSafeAreaBottom) { + bottomValue = `${bottom}px + var(--ion-safe-area-bottom, 0px)`; + } + contentEl.style.setProperty('bottom', `calc(${bottomValue})`); } - const safeAreaLeft = ' + var(--ion-safe-area-left, 0)'; - const safeAreaRight = ' - var(--ion-safe-area-right, 0)'; + const safeAreaLeft = ' + var(--ion-safe-area-left, 0px)'; + const safeAreaRight = ' - var(--ion-safe-area-right, 0px)'; let leftValue = `${left}px`; @@ -134,13 +161,18 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => leftValue = `${left}px${safeAreaRight}`; } - contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`); + let topValue = `${top}px`; + if (checkSafeAreaTop) { + topValue = `${top}px + var(--ion-safe-area-top, 0px)`; + } + + 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}`); if (arrowEl !== null) { const didAdjustBounds = results.top !== top || results.left !== left; - const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger); + const showArrow = !hideArrow && shouldShowArrow(side, didAdjustBounds, ev, trigger); if (showArrow) { arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`); diff --git a/core/src/components/popover/animations/md.enter.ts b/core/src/components/popover/animations/md.enter.ts index e25f745cec4..8de9976e86c 100644 --- a/core/src/components/popover/animations/md.enter.ts +++ b/core/src/components/popover/animations/md.enter.ts @@ -2,7 +2,7 @@ import { createAnimation } from '@utils/animation/animation'; import { getElementRoot } from '@utils/helpers'; import type { Animation } from '../../../interface'; -import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition } from '../utils'; +import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition, getSafeAreaInsets } from '../utils'; const POPOVER_MD_BODY_PADDING = 12; @@ -46,8 +46,22 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => ); const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING; - - const { originX, originY, top, left, bottom } = calculateWindowAdjustment( + // MD mode now applies safe-area insets (previously passed 0, ignoring all safe areas). + // This is needed for Android edge-to-edge (API 36+) where system bars overlap content. + const safeArea = size === 'cover' ? { top: 0, bottom: 0, left: 0, right: 0 } : getSafeAreaInsets(doc as Document); + + const { + originX, + originY, + top, + left, + bottom, + checkSafeAreaLeft, + checkSafeAreaRight, + checkSafeAreaTop, + checkSafeAreaBottom, + addPopoverBottomClass, + } = calculateWindowAdjustment( side, results.top, results.left, @@ -56,12 +70,28 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => bodyHeight, contentWidth, contentHeight, - 0, + safeArea, results.originX, results.originY, results.referenceCoordinates ); + const safeAreaLeftCalc = ' + var(--ion-safe-area-left, 0px)'; + const safeAreaRightCalc = ' - var(--ion-safe-area-right, 0px)'; + + let leftValue = `${left}px`; + if (checkSafeAreaLeft) { + leftValue = `${left}px${safeAreaLeftCalc}`; + } + if (checkSafeAreaRight) { + leftValue = `${left}px${safeAreaRightCalc}`; + } + + let topValue = `${top}px`; + if (checkSafeAreaTop) { + topValue = `${top}px + var(--ion-safe-area-top, 0px)`; + } + const baseAnimation = createAnimation(); const backdropAnimation = createAnimation(); const wrapperAnimation = createAnimation(); @@ -81,13 +111,17 @@ 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`); + let bottomValue = `${bottom}px`; + if (checkSafeAreaBottom) { + bottomValue = `${bottom}px + var(--ion-safe-area-bottom, 0px)`; + } + contentEl.style.setProperty('bottom', `calc(${bottomValue})`); } }) .fromTo('transform', 'scale(0.8)', 'scale(1)'); @@ -101,7 +135,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => if (size === 'cover') { baseEl.style.setProperty('--width', `${contentWidth}px`); } - if (originY === 'bottom') { + if (addPopoverBottomClass) { baseEl.classList.add('popover-bottom'); } }) 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..c9ee522b986 --- /dev/null +++ b/core/src/components/popover/test/safe-area/index.html @@ -0,0 +1,154 @@ + + + + + Popover - Safe Area + + + + + + + + + + + +
+
+
+
+ + + +

Popover safe area test. Triggers are positioned near screen edges.

+
+
+ + + + + + + + + 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..58cca8baa7c --- /dev/null +++ b/core/src/components/popover/test/safe-area/popover.e2e.ts @@ -0,0 +1,81 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * Safe-area behavior applies to both iOS and MD modes. + * Left/right safe areas are tested in landscape viewport. + */ +configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { + test.describe(title('popover: safe-area'), () => { + test.beforeEach(async ({ page }) => { + await page.goto('/src/components/popover/test/safe-area', config); + }); + + test('should not overlap the bottom safe area when trigger is near the bottom edge', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/28411', + }); + + /** + * Use a landscape-style viewport with bottom safe area. + * The bottom trigger is positioned at the very bottom of the screen. + */ + await page.setViewportSize({ + width: 414, + height: 400, + }); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + await page.locator('#bottom-trigger').click(); + await ionPopoverDidPresent.next(); + + await expect(page).toHaveScreenshot(screenshot('popover-safe-area-bottom')); + }); + + test('should not overlap the right safe area when trigger is near the right edge', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/28411', + }); + + /** + * Use a landscape viewport with left/right safe areas + * to simulate a device rotated to landscape with notch on left. + */ + await page.setViewportSize({ + width: 600, + height: 400, + }); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + await page.locator('#right-trigger').click(); + await ionPopoverDidPresent.next(); + + await expect(page).toHaveScreenshot(screenshot('popover-safe-area-right')); + }); + + test('should handle a large popover near the bottom-right corner without overlapping safe areas', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/28411', + }); + + await page.setViewportSize({ + width: 600, + height: 400, + }); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + + await page.locator('#large-popover-trigger').click(); + await ionPopoverDidPresent.next(); + + await expect(page).toHaveScreenshot(screenshot('popover-safe-area-large')); + }); + }); +}); diff --git a/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..7380d98ef50 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-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-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..fa56c8b3d61 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-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-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..10125b226cd Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-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-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..574cc7307a1 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-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-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..7d936598896 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-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-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..26d2d861dc4 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-bottom-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-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..d8ee77f057b Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-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-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..4692d2d3ea6 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-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-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..f2721a2c6e8 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-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-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..215d75de8ba Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-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-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..900782be5d9 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-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-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..9d89837628a Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-large-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-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..a04545790e4 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-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-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..4a9bcc9aa42 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-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-ios-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..14c84c9b58b Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-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-md-ltr-Mobile-Chrome-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..6e082daf219 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-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-md-ltr-Mobile-Firefox-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..abbd6c1406f Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-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-md-ltr-Mobile-Safari-linux.png b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..9a73a941665 Binary files /dev/null and b/core/src/components/popover/test/safe-area/popover.e2e.ts-snapshots/popover-safe-area-right-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..0d11a4dfeef 100644 --- a/core/src/components/popover/utils.ts +++ b/core/src/components/popover/utils.ts @@ -32,11 +32,79 @@ export interface PopoverStyles { originY: string; checkSafeAreaLeft: boolean; checkSafeAreaRight: boolean; + checkSafeAreaTop: boolean; + checkSafeAreaBottom: boolean; arrowTop: number; arrowLeft: number; addPopoverBottomClass: boolean; + hideArrow: boolean; } +export interface SafeAreaInsets { + top: number; + bottom: number; + left: number; + right: number; +} + +/** + * Shared per-frame cache for safe-area insets. Avoids creating a temporary + * DOM element and forcing a synchronous reflow on every call within the same + * frame (e.g., multiple popovers presenting simultaneously). Invalidated + * after the next animation frame so values stay fresh across orientation + * changes and viewport resizes. + */ +let cachedInsets: SafeAreaInsets | null = null; +let cacheInvalidationScheduled = false; + +/** + * Resolves the current --ion-safe-area-* CSS custom property values + * to actual pixel numbers. A temporary element is needed because + * getComputedStyle on :root returns the declared value of custom + * properties (e.g. "env(safe-area-inset-top)") rather than a + * resolved number. By assigning them to standard padding properties, + * the browser resolves the value. + * + * Results are cached for the current frame to avoid repeated reflows. + */ +export const getSafeAreaInsets = (doc: Document): SafeAreaInsets => { + if (cachedInsets !== null) { + return cachedInsets; + } + + if (doc.body === null) { + return { top: 0, bottom: 0, left: 0, right: 0 }; + } + + const el = doc.createElement('div'); + el.style.cssText = + 'position:fixed;visibility:hidden;pointer-events:none;top:0;left:0;' + + 'padding-top:var(--ion-safe-area-top,0px);' + + 'padding-bottom:var(--ion-safe-area-bottom,0px);' + + 'padding-left:var(--ion-safe-area-left,0px);' + + 'padding-right:var(--ion-safe-area-right,0px);'; + doc.body.appendChild(el); + const style = getComputedStyle(el); + const insets = { + top: parseFloat(style.paddingTop) || 0, + bottom: parseFloat(style.paddingBottom) || 0, + left: parseFloat(style.paddingLeft) || 0, + right: parseFloat(style.paddingRight) || 0, + }; + el.remove(); + + cachedInsets = insets; + if (!cacheInvalidationScheduled) { + cacheInvalidationScheduled = true; + raf(() => { + cachedInsets = null; + cacheInvalidationScheduled = false; + }); + } + + return insets; +}; + /** * Returns the dimensions of the popover * arrow on `ios` mode. If arrow is disabled @@ -804,6 +872,8 @@ const calculatePopoverCenterAlign = ( * Adjusts popover positioning coordinates * such that popover does not appear offscreen * or overlapping safe area bounds. + * + * @internal - This is not part of the public API. */ export const calculateWindowAdjustment = ( side: PositionSide, @@ -814,7 +884,7 @@ export const calculateWindowAdjustment = ( bodyHeight: number, contentWidth: number, contentHeight: number, - safeAreaMargin: number, + safeArea: SafeAreaInsets, contentOriginX: string, contentOriginY: string, triggerCoordinates?: ReferenceCoordinates, @@ -831,17 +901,20 @@ export const calculateWindowAdjustment = ( let originY = contentOriginY; let checkSafeAreaLeft = false; let checkSafeAreaRight = false; + let checkSafeAreaTop = false; + let checkSafeAreaBottom = false; const triggerTop = triggerCoordinates ? triggerCoordinates.top + triggerCoordinates.height : bodyHeight / 2 - contentHeight / 2; const triggerHeight = triggerCoordinates ? triggerCoordinates.height : 0; let addPopoverBottomClass = false; + const hideArrow = false; /** * Adjust popover so it does not * go off the left of the screen. */ - if (left < bodyPadding + safeAreaMargin) { + if (left < bodyPadding + safeArea.left) { left = bodyPadding; checkSafeAreaLeft = true; originX = 'left'; @@ -849,7 +922,7 @@ export const calculateWindowAdjustment = ( * Adjust popover so it does not * go off the right of the screen. */ - } else if (contentWidth + bodyPadding + left + safeAreaMargin > bodyWidth) { + } else if (contentWidth + bodyPadding + left + safeArea.right > bodyWidth) { checkSafeAreaRight = true; left = bodyWidth - contentWidth - bodyPadding; originX = 'right'; @@ -857,34 +930,54 @@ export const calculateWindowAdjustment = ( /** * Adjust popover so it does not - * go off the top of the screen. + * go off the bottom of the screen + * or overlap the bottom safe area. * If popover is on the left or the right of * the trigger, then we should not adjust top * margins. */ - if (triggerTop + triggerHeight + contentHeight > bodyHeight && (side === 'top' || side === 'bottom')) { - if (triggerTop - contentHeight > 0) { + if ( + triggerTop + triggerHeight + contentHeight > bodyHeight - safeArea.bottom && + (side === 'top' || side === 'bottom') + ) { + /** + * Calculate where the popover top would be if flipped + * above the trigger. Check whether that position clears + * the top safe area with room for bodyPadding. + */ + const idealFlipTop = triggerTop - contentHeight - triggerHeight - (arrowHeight - 1); + + if (idealFlipTop >= safeArea.top + bodyPadding) { /** - * While we strive to align the popover with the trigger - * on smaller screens this is not always possible. As a result, - * we adjust the popover up so that it does not hang - * off the bottom of the screen. However, we do not want to move - * the popover up so much that it goes off the top of the screen. - * - * 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. + * Popover fits above the trigger without overlapping + * the top safe area. Use the ideal position directly — + * no safe-area CSS vars needed since it clears both edges. */ - top = Math.max(12, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1)); + top = idealFlipTop; arrowTop = top + contentHeight; originY = 'bottom'; addPopoverBottomClass = true; - + } else { /** - * If not enough room for popover to appear - * above trigger, then cut it off. + * Can't flip above the trigger. Constrain the bottom + * edge while keeping the top near the trigger. This + * creates a scrollable area anchored to the trigger. */ - } else { bottom = bodyPadding; + checkSafeAreaBottom = true; + + /** + * When the trigger is near the bottom of the screen, + * the calculated top may be at or past the bottom + * constraint, leaving zero visible height. In that + * case, pull top up to the top safe area so the + * popover fills the available space between safe areas. + */ + const bottomEdge = bodyHeight - safeArea.bottom - bodyPadding; + if (top >= bottomEdge) { + top = safeArea.top + bodyPadding; + checkSafeAreaTop = true; + } } } @@ -896,9 +989,12 @@ export const calculateWindowAdjustment = ( originY, checkSafeAreaLeft, checkSafeAreaRight, + checkSafeAreaTop, + checkSafeAreaBottom, arrowTop, arrowLeft, addPopoverBottomClass, + hideArrow, }; };