Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c54f257
fix(modal): respect safe area insets on tablet-sized screens
ShaneK Dec 23, 2025
f66c84a
fix(modal): dynamically apply safe-area insets based on viewport edge…
ShaneK Dec 26, 2025
fea0a3d
fix(modal): dynamically handle safe-area insets based on modal type a…
ShaneK Dec 26, 2025
b87cd07
chore(): add updated snapshots
Ionitron Dec 26, 2025
61dc7eb
chore(): add updated snapshots
Ionitron Dec 26, 2025
415245b
resetting unchanged snapshot
ShaneK Dec 26, 2025
4b7f2fa
Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-…
ShaneK Dec 26, 2025
61b588c
Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-…
ShaneK Dec 31, 2025
3557925
fix(modal): dynamically handle safe-area insets for edge-to-edge mode
ShaneK Dec 31, 2025
3fac5cc
chore(): add updated snapshots
Ionitron Dec 31, 2025
d6eb8ce
fix(modal): apply safe-area padding to card modals on phones
ShaneK Jan 2, 2026
fa16c3a
chore: test fix
ShaneK Jan 2, 2026
39b15cb
chore: fixing phone viewport tests
ShaneK Jan 2, 2026
9c404a6
Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-…
ShaneK Jan 5, 2026
4a165bc
fix(modal): correct safe-area handling for MD mode and edge detection
ShaneK Jan 5, 2026
7c197c2
fix(popover): extending safe are protections to top/bottom overlap
ShaneK Jan 5, 2026
4fe98a4
fix(content): apply safe-area insets when header/footer absent
ShaneK Jan 6, 2026
fc49604
chore(test): zero out safe-area insets in test environments
ShaneK Jan 6, 2026
48e4bc4
fix(content): detect header/footer wrapped in custom components
ShaneK Jan 7, 2026
a5bd1dd
chore(): add updated snapshots
Ionitron Jan 7, 2026
553aa65
chore(tests): fixing tests having issues with mutation observers
ShaneK Jan 7, 2026
6fb604a
chore(): add updated snapshots
Ionitron Jan 7, 2026
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
17 changes: 17 additions & 0 deletions core/src/components/content/content.scss
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,23 @@
}


// Content: Safe Area
// --------------------------------------------------
// When content has no sibling header, offset from top safe-area.
// When content has no sibling footer/tab-bar, offset from bottom safe-area.
// This prevents content from overlapping device safe areas (status bar, nav bar).

:host(.safe-area-top) #background-content,
:host(.safe-area-top) .inner-scroll {
top: var(--ion-safe-area-top, 0px);
}

:host(.safe-area-bottom) #background-content,
:host(.safe-area-bottom) .inner-scroll {
bottom: var(--ion-safe-area-bottom, 0px);
}


// Content: Fixed
// --------------------------------------------------

Expand Down
86 changes: 85 additions & 1 deletion core/src/components/content/content.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Build, Component, Element, Event, Host, Listen, Method, Prop, forceUpdate, h, readTask } from '@stencil/core';
import { win } from '@utils/browser';
import { componentOnReady, hasLazyBuild, inheritAriaAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { isPlatform } from '@utils/platform';
Expand Down Expand Up @@ -36,6 +37,16 @@ export class Content implements ComponentInterface {
private resizeTimeout: ReturnType<typeof setTimeout> | null = null;
private inheritedAttributes: Attributes = {};

/**
* Track whether this content has sibling header/footer elements.
* When absent, we need to apply safe-area padding directly.
*/
private hasHeader = false;
private hasFooter = false;

/** Watches for dynamic header/footer changes in parent element */
private parentMutationObserver?: MutationObserver;

private tabsElement: HTMLElement | null = null;
private tabsLoadCallback?: () => void;

Expand Down Expand Up @@ -134,6 +145,9 @@ export class Content implements ComponentInterface {
connectedCallback() {
this.isMainContent = this.el.closest('ion-menu, ion-popover, ion-modal') === null;

// Detect sibling header/footer for safe-area handling
this.detectSiblingElements();

/**
* The fullscreen content offsets need to be
* computed after the tab bar has loaded. Since
Expand Down Expand Up @@ -170,9 +184,77 @@ export class Content implements ComponentInterface {
}
}

/**
* Detects sibling ion-header and ion-footer elements and sets up
* a mutation observer to handle dynamic changes (e.g., conditional rendering).
*/
private detectSiblingElements() {
this.updateSiblingDetection();

// Watch for dynamic header/footer changes (common in React conditional rendering)
const parent = this.el.parentElement;
if (parent && !this.parentMutationObserver && win !== undefined && 'MutationObserver' in win) {
this.parentMutationObserver = new MutationObserver(() => {
this.updateSiblingDetection();
forceUpdate(this);
});
this.parentMutationObserver.observe(parent, { childList: true });
}
}

/**
* Updates hasHeader/hasFooter based on current DOM state.
* Checks both direct siblings and elements wrapped in custom components
* (e.g., <my-header><ion-header>...</ion-header></my-header>).
*/
private updateSiblingDetection() {
const parent = this.el.parentElement;
if (parent) {
// First check for direct ion-header/ion-footer siblings
this.hasHeader = parent.querySelector(':scope > ion-header') !== null;
this.hasFooter = parent.querySelector(':scope > ion-footer') !== null;

// If not found, check if any sibling contains them (wrapped components)
if (!this.hasHeader) {
this.hasHeader = this.siblingContainsElement(parent, 'ion-header');
}
if (!this.hasFooter) {
this.hasFooter = this.siblingContainsElement(parent, 'ion-footer');
}
}

// If no footer found, check if we're inside ion-tabs which has ion-tab-bar
if (!this.hasFooter) {
const tabs = this.el.closest('ion-tabs');
if (tabs) {
this.hasFooter = tabs.querySelector(':scope > ion-tab-bar') !== null;
}
}
}

/**
* Checks if any sibling element of ion-content contains the specified element.
* Only searches one level deep to avoid finding elements in nested pages.
*/
private siblingContainsElement(parent: Element, tagName: string): boolean {
for (const sibling of parent.children) {
// Skip ion-content itself
if (sibling === this.el) continue;
// Check if this sibling contains the target element as an immediate child
if (sibling.querySelector(`:scope > ${tagName}`) !== null) {
return true;
}
}
return false;
}

disconnectedCallback() {
this.onScrollEnd();

// Clean up mutation observer to prevent memory leaks
this.parentMutationObserver?.disconnect();
this.parentMutationObserver = undefined;

if (hasLazyBuild(this.el)) {
/**
* The event listener and tabs caches need to
Expand Down Expand Up @@ -449,7 +531,7 @@ export class Content implements ComponentInterface {
}

render() {
const { fixedSlotPlacement, inheritedAttributes, isMainContent, scrollX, scrollY, el } = this;
const { fixedSlotPlacement, hasFooter, hasHeader, inheritedAttributes, isMainContent, scrollX, scrollY, el } = this;
const rtl = isRTL(el) ? 'rtl' : 'ltr';
const mode = getIonMode(this);
const forceOverscroll = this.shouldForceOverscroll();
Expand All @@ -465,6 +547,8 @@ export class Content implements ComponentInterface {
'content-sizing': hostContext('ion-popover', this.el),
overscroll: forceOverscroll,
[`content-${rtl}`]: true,
'safe-area-top': isMainContent && !hasHeader,
'safe-area-bottom': isMainContent && !hasFooter,
})}
style={{
'--offset-top': `${this.cTop}px`,
Expand Down
6 changes: 5 additions & 1 deletion core/src/components/modal/gestures/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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) => {
Expand Down
6 changes: 5 additions & 1 deletion core/src/components/modal/gestures/swipe-to-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
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
Loading
Loading