Skip to content

Commit 48e4bc4

Browse files
committed
fix(content): detect header/footer wrapped in custom components
1 parent fc49604 commit 48e4bc4

File tree

2 files changed

+134
-24
lines changed

2 files changed

+134
-24
lines changed

core/src/components/content/content.tsx

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export class Content implements ComponentInterface {
4343
private hasHeader = false;
4444
private hasFooter = false;
4545

46+
/** Watches for dynamic header/footer changes in parent element */
47+
private parentMutationObserver?: MutationObserver;
48+
4649
private tabsElement: HTMLElement | null = null;
4750
private tabsLoadCallback?: () => void;
4851

@@ -181,15 +184,42 @@ export class Content implements ComponentInterface {
181184
}
182185

183186
/**
184-
* Detects sibling ion-header and ion-footer elements.
185-
* When these are absent, content needs to handle safe-area padding directly.
187+
* Detects sibling ion-header and ion-footer elements and sets up
188+
* a mutation observer to handle dynamic changes (e.g., conditional rendering).
186189
*/
187190
private detectSiblingElements() {
188-
// Check parent element for sibling header/footer.
191+
this.updateSiblingDetection();
192+
193+
// Watch for dynamic header/footer changes (common in React conditional rendering)
194+
const parent = this.el.parentElement;
195+
if (parent && !this.parentMutationObserver) {
196+
this.parentMutationObserver = new MutationObserver(() => {
197+
this.updateSiblingDetection();
198+
forceUpdate(this);
199+
});
200+
this.parentMutationObserver.observe(parent, { childList: true });
201+
}
202+
}
203+
204+
/**
205+
* Updates hasHeader/hasFooter based on current DOM state.
206+
* Checks both direct siblings and elements wrapped in custom components
207+
* (e.g., <my-header><ion-header>...</ion-header></my-header>).
208+
*/
209+
private updateSiblingDetection() {
189210
const parent = this.el.parentElement;
190211
if (parent) {
212+
// First check for direct ion-header/ion-footer siblings
191213
this.hasHeader = parent.querySelector(':scope > ion-header') !== null;
192214
this.hasFooter = parent.querySelector(':scope > ion-footer') !== null;
215+
216+
// If not found, check if any sibling contains them (wrapped components)
217+
if (!this.hasHeader) {
218+
this.hasHeader = this.siblingContainsElement(parent, 'ion-header');
219+
}
220+
if (!this.hasFooter) {
221+
this.hasFooter = this.siblingContainsElement(parent, 'ion-footer');
222+
}
193223
}
194224

195225
// 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 {
201231
}
202232
}
203233

234+
/**
235+
* Checks if any sibling element of ion-content contains the specified element.
236+
* Only searches one level deep to avoid finding elements in nested pages.
237+
*/
238+
private siblingContainsElement(parent: Element, tagName: string): boolean {
239+
for (const sibling of parent.children) {
240+
// Skip ion-content itself
241+
if (sibling === this.el) continue;
242+
// Check if this sibling contains the target element as an immediate child
243+
if (sibling.querySelector(`:scope > ${tagName}`) !== null) {
244+
return true;
245+
}
246+
}
247+
return false;
248+
}
249+
204250
disconnectedCallback() {
205251
this.onScrollEnd();
206252

253+
// Clean up mutation observer to prevent memory leaks
254+
this.parentMutationObserver?.disconnect();
255+
this.parentMutationObserver = undefined;
256+
207257
if (hasLazyBuild(this.el)) {
208258
/**
209259
* The event listener and tabs caches need to

core/src/components/modal/modal.tsx

Lines changed: 81 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,18 @@ export class Modal implements ComponentInterface, OverlayInterface {
9898

9999
// Mutation observer to watch for parent removal
100100
private parentRemovalObserver?: MutationObserver;
101+
// Watches for dynamic footer additions/removals to update safe-area padding
102+
private footerObserver?: MutationObserver;
101103
// Cached original parent from before modal is moved to body during presentation
102104
private cachedOriginalParent?: HTMLElement;
103105
// Cached ion-page ancestor for child route passthrough
104106
private cachedPageParent?: HTMLElement | null;
105107
// Whether to skip coordinate-based safe-area detection (for fullscreen phone modals)
106108
private skipSafeAreaCoordinateDetection = false;
109+
// Cached safe-area values to avoid getComputedStyle calls during gestures
110+
private cachedSafeAreas?: { top: number; bottom: number; left: number; right: number };
111+
// Track previous safe-area state to avoid redundant DOM writes
112+
private prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
107113

108114
lastFocus?: HTMLElement;
109115
animation?: Animation;
@@ -278,7 +284,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
278284

279285
@Listen('resize', { target: 'window' })
280286
onWindowResize() {
281-
// Update safe-area overrides for all modal types on resize
287+
// Invalidate safe-area cache on resize (device rotation may change values)
288+
this.cachedSafeAreas = undefined;
282289
this.updateSafeAreaOverrides();
283290

284291
// Only handle view transition for iOS card modals when no custom animations are provided
@@ -931,9 +938,27 @@ export class Modal implements ComponentInterface, OverlayInterface {
931938
*/
932939
private applyFullscreenSafeArea() {
933940
this.skipSafeAreaCoordinateDetection = true;
941+
this.updateFooterPadding();
942+
943+
// Watch for dynamic footer additions/removals (e.g., async data loading)
944+
if (!this.footerObserver) {
945+
this.footerObserver = new MutationObserver(() => this.updateFooterPadding());
946+
this.footerObserver.observe(this.el, { childList: true, subtree: true });
947+
}
948+
}
949+
950+
/**
951+
* Updates wrapper padding based on footer presence.
952+
* Called initially and when footer is dynamically added/removed.
953+
*/
954+
private updateFooterPadding() {
955+
if (!this.wrapperEl) return;
934956

935957
const hasFooter = this.el.querySelector('ion-footer') !== null;
936-
if (!hasFooter && this.wrapperEl) {
958+
if (hasFooter) {
959+
this.wrapperEl.style.removeProperty('padding-bottom');
960+
this.wrapperEl.style.removeProperty('box-sizing');
961+
} else {
937962
this.wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
938963
this.wrapperEl.style.setProperty('box-sizing', 'border-box');
939964
}
@@ -953,22 +978,27 @@ export class Modal implements ComponentInterface, OverlayInterface {
953978

954979
/**
955980
* Gets the root safe-area values from the document element.
956-
* These represent the actual device safe areas before any overlay overrides.
981+
* Uses cached values during gestures to avoid getComputedStyle calls.
957982
*/
958-
private getRootSafeAreaValues(): { top: number; bottom: number; left: number; right: number } {
959-
const rootStyle = getComputedStyle(document.documentElement);
960-
return {
961-
top: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-top')) || 0,
962-
bottom: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-bottom')) || 0,
963-
left: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-left')) || 0,
964-
right: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-right')) || 0,
965-
};
983+
private getSafeAreaValues(): { top: number; bottom: number; left: number; right: number } {
984+
if (!this.cachedSafeAreas) {
985+
const rootStyle = getComputedStyle(document.documentElement);
986+
this.cachedSafeAreas = {
987+
top: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-top')) || 0,
988+
bottom: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-bottom')) || 0,
989+
left: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-left')) || 0,
990+
right: parseFloat(rootStyle.getPropertyValue('--ion-safe-area-right')) || 0,
991+
};
992+
}
993+
return this.cachedSafeAreas;
966994
}
967995

968996
/**
969997
* Updates safe-area CSS variable overrides based on whether the modal
970998
* extends into each safe-area region. Called after animation
971999
* and during gestures to handle dynamic position changes.
1000+
*
1001+
* Optimized to avoid redundant DOM writes by tracking previous state.
9721002
*/
9731003
private updateSafeAreaOverrides() {
9741004
if (this.skipSafeAreaCoordinateDetection) {
@@ -981,22 +1011,37 @@ export class Modal implements ComponentInterface, OverlayInterface {
9811011
}
9821012

9831013
const rect = wrapper.getBoundingClientRect();
984-
const safeAreas = this.getRootSafeAreaValues();
1014+
const safeAreas = this.getSafeAreaValues();
9851015

9861016
const extendsIntoTop = rect.top < safeAreas.top;
9871017
const extendsIntoBottom = rect.bottom > window.innerHeight - safeAreas.bottom;
9881018
const extendsIntoLeft = rect.left < safeAreas.left;
9891019
const extendsIntoRight = rect.right > window.innerWidth - safeAreas.right;
9901020

1021+
// Only update DOM when state actually changes
1022+
const prev = this.prevSafeAreaState;
9911023
const style = this.el.style;
992-
extendsIntoTop ? style.removeProperty('--ion-safe-area-top') : style.setProperty('--ion-safe-area-top', '0px');
993-
extendsIntoBottom
994-
? style.removeProperty('--ion-safe-area-bottom')
995-
: style.setProperty('--ion-safe-area-bottom', '0px');
996-
extendsIntoLeft ? style.removeProperty('--ion-safe-area-left') : style.setProperty('--ion-safe-area-left', '0px');
997-
extendsIntoRight
998-
? style.removeProperty('--ion-safe-area-right')
999-
: style.setProperty('--ion-safe-area-right', '0px');
1024+
1025+
if (extendsIntoTop !== prev.top) {
1026+
extendsIntoTop ? style.removeProperty('--ion-safe-area-top') : style.setProperty('--ion-safe-area-top', '0px');
1027+
prev.top = extendsIntoTop;
1028+
}
1029+
if (extendsIntoBottom !== prev.bottom) {
1030+
extendsIntoBottom
1031+
? style.removeProperty('--ion-safe-area-bottom')
1032+
: style.setProperty('--ion-safe-area-bottom', '0px');
1033+
prev.bottom = extendsIntoBottom;
1034+
}
1035+
if (extendsIntoLeft !== prev.left) {
1036+
extendsIntoLeft ? style.removeProperty('--ion-safe-area-left') : style.setProperty('--ion-safe-area-left', '0px');
1037+
prev.left = extendsIntoLeft;
1038+
}
1039+
if (extendsIntoRight !== prev.right) {
1040+
extendsIntoRight
1041+
? style.removeProperty('--ion-safe-area-right')
1042+
: style.setProperty('--ion-safe-area-right', '0px');
1043+
prev.right = extendsIntoRight;
1044+
}
10001045
}
10011046

10021047
private sheetOnDismiss() {
@@ -1111,8 +1156,23 @@ export class Modal implements ComponentInterface, OverlayInterface {
11111156
}
11121157
this.currentBreakpoint = undefined;
11131158
this.animation = undefined;
1114-
// Reset safe-area detection flag for potential re-presentation
1159+
// Reset safe-area state for potential re-presentation
11151160
this.skipSafeAreaCoordinateDetection = false;
1161+
this.cachedSafeAreas = undefined;
1162+
this.prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
1163+
this.footerObserver?.disconnect();
1164+
this.footerObserver = undefined;
1165+
// Clear styles that may have been set for safe-area handling
1166+
if (this.wrapperEl) {
1167+
this.wrapperEl.style.removeProperty('padding-bottom');
1168+
this.wrapperEl.style.removeProperty('box-sizing');
1169+
}
1170+
// Clear safe-area CSS variable overrides
1171+
const style = this.el.style;
1172+
style.removeProperty('--ion-safe-area-top');
1173+
style.removeProperty('--ion-safe-area-bottom');
1174+
style.removeProperty('--ion-safe-area-left');
1175+
style.removeProperty('--ion-safe-area-right');
11161176

11171177
unlock();
11181178

0 commit comments

Comments
 (0)