mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
fix(content): detect header/footer wrapped in custom components
This commit is contained in:
@@ -43,6 +43,9 @@ export class Content implements ComponentInterface {
|
||||
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;
|
||||
|
||||
@@ -181,15 +184,42 @@ export class Content implements ComponentInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects sibling ion-header and ion-footer elements.
|
||||
* When these are absent, content needs to handle safe-area padding directly.
|
||||
* Detects sibling ion-header and ion-footer elements and sets up
|
||||
* a mutation observer to handle dynamic changes (e.g., conditional rendering).
|
||||
*/
|
||||
private detectSiblingElements() {
|
||||
// Check parent element for sibling header/footer.
|
||||
this.updateSiblingDetection();
|
||||
|
||||
// Watch for dynamic header/footer changes (common in React conditional rendering)
|
||||
const parent = this.el.parentElement;
|
||||
if (parent && !this.parentMutationObserver) {
|
||||
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
|
||||
@@ -201,9 +231,29 @@ export class Content implements ComponentInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -98,12 +98,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;
|
||||
@@ -278,7 +284,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
|
||||
@Listen('resize', { target: 'window' })
|
||||
onWindowResize() {
|
||||
// Update safe-area overrides for all modal types on resize
|
||||
// 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
|
||||
@@ -931,9 +938,27 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
*/
|
||||
private applyFullscreenSafeArea() {
|
||||
this.skipSafeAreaCoordinateDetection = true;
|
||||
this.updateFooterPadding();
|
||||
|
||||
// Watch for dynamic footer additions/removals (e.g., async data loading)
|
||||
if (!this.footerObserver) {
|
||||
this.footerObserver = new MutationObserver(() => this.updateFooterPadding());
|
||||
this.footerObserver.observe(this.el, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates wrapper padding based on footer presence.
|
||||
* Called initially and when footer is dynamically added/removed.
|
||||
*/
|
||||
private updateFooterPadding() {
|
||||
if (!this.wrapperEl) return;
|
||||
|
||||
const hasFooter = this.el.querySelector('ion-footer') !== null;
|
||||
if (!hasFooter && this.wrapperEl) {
|
||||
if (hasFooter) {
|
||||
this.wrapperEl.style.removeProperty('padding-bottom');
|
||||
this.wrapperEl.style.removeProperty('box-sizing');
|
||||
} else {
|
||||
this.wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
|
||||
this.wrapperEl.style.setProperty('box-sizing', 'border-box');
|
||||
}
|
||||
@@ -953,22 +978,27 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
|
||||
/**
|
||||
* Gets the root safe-area values from the document element.
|
||||
* These represent the actual device safe areas before any overlay overrides.
|
||||
* Uses cached values during gestures to avoid getComputedStyle calls.
|
||||
*/
|
||||
private getRootSafeAreaValues(): { top: number; bottom: number; left: number; right: number } {
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
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,
|
||||
};
|
||||
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) {
|
||||
@@ -981,22 +1011,37 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
}
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const safeAreas = this.getRootSafeAreaValues();
|
||||
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;
|
||||
extendsIntoTop ? style.removeProperty('--ion-safe-area-top') : style.setProperty('--ion-safe-area-top', '0px');
|
||||
extendsIntoBottom
|
||||
? style.removeProperty('--ion-safe-area-bottom')
|
||||
: style.setProperty('--ion-safe-area-bottom', '0px');
|
||||
extendsIntoLeft ? style.removeProperty('--ion-safe-area-left') : style.setProperty('--ion-safe-area-left', '0px');
|
||||
extendsIntoRight
|
||||
? style.removeProperty('--ion-safe-area-right')
|
||||
: style.setProperty('--ion-safe-area-right', '0px');
|
||||
|
||||
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() {
|
||||
@@ -1111,8 +1156,23 @@ export class Modal implements ComponentInterface, OverlayInterface {
|
||||
}
|
||||
this.currentBreakpoint = undefined;
|
||||
this.animation = undefined;
|
||||
// Reset safe-area detection flag for potential re-presentation
|
||||
// Reset safe-area state for potential re-presentation
|
||||
this.skipSafeAreaCoordinateDetection = false;
|
||||
this.cachedSafeAreas = undefined;
|
||||
this.prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
|
||||
this.footerObserver?.disconnect();
|
||||
this.footerObserver = undefined;
|
||||
// Clear styles that may have been set for safe-area handling
|
||||
if (this.wrapperEl) {
|
||||
this.wrapperEl.style.removeProperty('padding-bottom');
|
||||
this.wrapperEl.style.removeProperty('box-sizing');
|
||||
}
|
||||
// Clear safe-area CSS variable overrides
|
||||
const style = this.el.style;
|
||||
style.removeProperty('--ion-safe-area-top');
|
||||
style.removeProperty('--ion-safe-area-bottom');
|
||||
style.removeProperty('--ion-safe-area-left');
|
||||
style.removeProperty('--ion-safe-area-right');
|
||||
|
||||
unlock();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user