Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 0 additions & 4 deletions core/src/components/modal/modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
147 changes: 139 additions & 8 deletions core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ 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,
type ModalSafeAreaContext,
} from './safe-area-utils';
import { setCardStatusBarDark, setCardStatusBarDefault } from './utils';

// TODO(FW-2832): types
Expand Down Expand Up @@ -406,6 +413,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() {
Expand Down Expand Up @@ -594,6 +604,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;

/**
Expand All @@ -614,6 +631,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') {
/**
Expand Down Expand Up @@ -646,14 +669,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();
Expand Down Expand Up @@ -754,6 +770,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
if (this.currentBreakpoint !== breakpoint) {
this.currentBreakpoint = breakpoint;
this.ionBreakpointDidChange.emit({ breakpoint });
// Update safe-area overrides based on new position
this.updateSafeAreaOverrides();
}
}
);
Expand Down Expand Up @@ -956,6 +974,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
this.cleanupViewTransitionListener();
this.cleanupParentRemovalObserver();
this.cleanupSafeAreaOverrides();

this.cleanupChildRoutePassthrough();
}
Expand Down Expand Up @@ -1166,6 +1185,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();
Expand Down Expand Up @@ -1335,6 +1358,114 @@ 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.
*/
private setInitialSafeAreaOverrides(): void {
const context = this.getSafeAreaContext();
const safeAreaConfig = getInitialSafeAreaConfig(context);
applySafeAreaOverrides(this.el, safeAreaConfig);
}

/**
* 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: the wrapper extends beyond the viewport and is translated
// via breakpoint gestures, making getBoundingClientRect unreliable for
// edge detection. Instead, use breakpoint value to determine top safe-area.
if (context.isSheetModal) {
const needsTopSafeArea = context.currentBreakpoint === 1;
applySafeAreaOverrides(el, {
top: needsTopSafeArea ? 'inherit' : '0px',
bottom: 'inherit',
left: '0px',
right: '0px',
});
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.,
// <app-footer><ion-footer>...</ion-footer></app-footer>).
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);

if (this.wrapperEl) {
this.wrapperEl.style.removeProperty('height');
this.wrapperEl.style.removeProperty('padding-bottom');
}
}

render() {
const {
handle,
Expand Down
151 changes: 151 additions & 0 deletions core/src/components/modal/safe-area-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { win } from '@utils/browser';

/**
* Configuration for safe-area CSS custom properties.
* Values can be 'inherit' (use root value) or '0px' (no safe-area).
*/
export interface SafeAreaConfig {
top: string;
bottom: string;
left: string;
right: string;
}

/**
* 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;

/**
* 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;
};

/**
* 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 use bottom safe-area, and top safe-area only when fully expanded
if (isSheetModal) {
return {
top: context.currentBreakpoint === 1 ? 'inherit' : '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');
};
Loading
Loading