From 48e4bc47764226e129f02737f12e3b9383ed53af Mon Sep 17 00:00:00 2001 From: ShaneK Date: Wed, 7 Jan 2026 05:47:21 -0800 Subject: [PATCH] fix(content): detect header/footer wrapped in custom components --- core/src/components/content/content.tsx | 56 ++++++++++++- core/src/components/modal/modal.tsx | 102 +++++++++++++++++++----- 2 files changed, 134 insertions(+), 24 deletions(-) diff --git a/core/src/components/content/content.tsx b/core/src/components/content/content.tsx index fb9845c47a..ac00d3b6ff 100644 --- a/core/src/components/content/content.tsx +++ b/core/src/components/content/content.tsx @@ -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., ...). + */ + 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 diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 8a16de465b..f92b468635 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -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();