Skip to content

Commit ada35da

Browse files
committed
fix(content): automatically recalculate offset when header/footer changes
Previously, ion-content only calculated layout offsets on initialization or window resize. This caused content to be covered or obscured if a header or footer's dimensions changed dynamically (e.g. expanding a searchbar) or if they were added conditionally to the DOM. This implementation adds a ResizeObserver to watch sibling headers/footers for size changes and a MutationObserver to detect when they are added, triggering an automatic recalculation of the --offset-top and --offset-bottom CSS variables. Fixes #26981
1 parent f50994a commit ada35da

21 files changed

+196
-0
lines changed

core/src/components/content/content.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import type { ScrollBaseDetail, ScrollDetail } from './content-interface';
2525
})
2626
export class Content implements ComponentInterface {
2727
private watchDog: ReturnType<typeof setInterval> | null = null;
28+
private mutationObserver: MutationObserver | null = null;
29+
private resizeObserver: ResizeObserver | null = null;
2830
private isScrolling = false;
2931
private lastScroll = 0;
3032
private queued = false;
@@ -168,10 +170,12 @@ export class Content implements ComponentInterface {
168170
closestTabs.addEventListener('ionTabBarLoaded', this.tabsLoadCallback);
169171
}
170172
}
173+
this.connectObservers();
171174
}
172175

173176
disconnectedCallback() {
174177
this.onScrollEnd();
178+
this.disconnectObservers();
175179

176180
if (hasLazyBuild(this.el)) {
177181
/**
@@ -420,6 +424,85 @@ export class Content implements ComponentInterface {
420424
return promise;
421425
}
422426

427+
/**
428+
* We need to observe the parent element to detect when
429+
* <ion-header> or <ion-footer> elements are added/removed
430+
* or resized. This ensures the content offset is recalculated
431+
* dynamically.
432+
*/
433+
private connectObservers() {
434+
if (!Build.isBrowser) {
435+
return;
436+
}
437+
438+
const parent = this.el.parentElement;
439+
if (!parent) {
440+
return;
441+
}
442+
443+
if ('ResizeObserver' in window) {
444+
this.resizeObserver = new ResizeObserver(() => {
445+
this.resize();
446+
});
447+
}
448+
449+
if ('MutationObserver' in window) {
450+
this.mutationObserver = new MutationObserver((mutations) => {
451+
let shouldUpdate = false;
452+
453+
for (const mutation of mutations) {
454+
if (mutation.type === 'childList') {
455+
mutation.addedNodes.forEach((node: any) => {
456+
if (node.tagName === 'ION-HEADER' || node.tagName === 'ION-FOOTER') {
457+
shouldUpdate = true;
458+
}
459+
});
460+
461+
mutation.removedNodes.forEach((node: any) => {
462+
if (node.tagName === 'ION-HEADER' || node.tagName === 'ION-FOOTER') {
463+
shouldUpdate = true;
464+
}
465+
});
466+
}
467+
}
468+
469+
if (shouldUpdate) {
470+
this.refreshResizeObserver();
471+
this.resize();
472+
}
473+
});
474+
475+
this.mutationObserver.observe(parent, { childList: true });
476+
}
477+
478+
this.refreshResizeObserver();
479+
}
480+
481+
private disconnectObservers() {
482+
if (this.mutationObserver) {
483+
this.mutationObserver.disconnect();
484+
this.mutationObserver = null;
485+
}
486+
if (this.resizeObserver) {
487+
this.resizeObserver.disconnect();
488+
this.resizeObserver = null;
489+
}
490+
}
491+
492+
private refreshResizeObserver() {
493+
if (!this.resizeObserver || !this.el.parentElement) {
494+
return;
495+
}
496+
497+
this.resizeObserver.disconnect();
498+
499+
const headers = this.el.parentElement.querySelectorAll('ion-header');
500+
const footers = this.el.parentElement.querySelectorAll('ion-footer');
501+
502+
headers.forEach((header) => this.resizeObserver!.observe(header));
503+
footers.forEach((footer) => this.resizeObserver!.observe(footer));
504+
}
505+
423506
private onScrollStart() {
424507
this.isScrolling = true;
425508
this.ionScrollStart.emit({
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { expect } from '@playwright/test';
2+
import { test, configs } from '@utils/test/playwright';
3+
4+
configs({ modes: ['ios'] }).forEach(({ title, screenshot, config }) => {
5+
test.describe(title('content: auto offset'), () => {
6+
7+
test('should not have visual regressions', async ({ page }) => {
8+
await page.goto(`/src/components/content/test/auto-offset`, config);
9+
await page.setIonViewport();
10+
await expect(page).toHaveScreenshot(screenshot(`content-auto-offset-initial`));
11+
});
12+
13+
test('should update offsets when header height changes', async ({ page }) => {
14+
await page.goto(`/src/components/content/test/auto-offset`, config);
15+
await page.setIonViewport();
16+
17+
const content = page.locator('ion-content');
18+
const before = await content.evaluate((el: HTMLElement) =>
19+
getComputedStyle(el).getPropertyValue('--offset-top')
20+
);
21+
22+
await page.click('#expand-header-btn');
23+
24+
await expect(content).not.toHaveCSS('--offset-top', before);
25+
26+
await expect(page).toHaveScreenshot(screenshot(`content-auto-offset-header-updated`));
27+
});
28+
29+
test('should update offsets when footer height changes', async ({ page }) => {
30+
await page.goto(`/src/components/content/test/auto-offset`, config);
31+
await page.setIonViewport();
32+
33+
const content = page.locator('ion-content');
34+
const before = await content.evaluate((el: HTMLElement) =>
35+
getComputedStyle(el).getPropertyValue('--offset-bottom')
36+
);
37+
38+
await page.click('#expand-footer-btn');
39+
40+
await expect(content).not.toHaveCSS('--offset-bottom', before);
41+
42+
const after = await content.evaluate((el: HTMLElement) =>
43+
getComputedStyle(el).getPropertyValue('--offset-bottom')
44+
);
45+
46+
expect(after).not.toBe(before);
47+
48+
await expect(page).toHaveScreenshot(screenshot(`content-auto-offset-footer-updated`));
49+
});
50+
51+
});
52+
});
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading

0 commit comments

Comments
 (0)