fix(content): detect header/footer wrapped in custom components

This commit is contained in:
ShaneK
2026-01-07 05:47:21 -08:00
parent fc496043d8
commit 48e4bc4776
2 changed files with 134 additions and 24 deletions

View File

@@ -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

View File

@@ -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();