Skip to content

Commit f3cd39b

Browse files
authored
fix(modal): remove safe-area gap and flash in fullscreen modals (#31092)
Issue number: resolves #31015 --------- ## What is the current behavior? Fullscreen modals without an `ion-footer` set `height` and `padding-bottom` on `.modal-wrapper` to reserve space for the bottom safe-area. Because `ion-content`'s background does not extend into that wrapper padding, a visible gap appears between the content and the bottom edge of the modal. Separately, modals that declare custom `--width` and `--height` on phone viewports flash inherited safe-area values while animating in, then snap to `0px` once the post-animation position correction runs. ## What is the new behavior? Bottom safe-area compensation now happens inside `ion-content` instead of on the wrapper, so the modal background stays edge-to-edge. - New internal `--ion-content-safe-area-padding-bottom` CSS property is added to `.inner-scroll`'s `padding-bottom` calc. - `modal.tsx` sets that property on `ion-content` for fullscreen modals without a footer, and clears it on resize and dismiss. - Wrapper no longer has `height` or `padding-bottom` inline styles written to it. - New `hasCustomModalDimensions()` helper detects modals that override both `--width` and `--height` to non-fullscreen values. These are zeroed from the initial safe-area prediction so there is no flash before the post-animation correction. - Extracted `findContentAndFooter()` and `applyFullscreenSafeAreaTo()` helpers so the resize handler and initial apply share the same lookup. - E2E tests cover scroll-padding placement, wrapper content area remaining full viewport height, `.inner-scroll` padding including safe-area, and custom-dimension modals starting at zero. ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information `--ion-content-safe-area-padding-bottom` is an internal property. It is additive with the existing `--padding-bottom` consumers can set, and it only takes effect when `modal.tsx` writes it on fullscreen modals without a footer. Example pages: - iOS: https://ionic-framework-git-fw-7136-ionic1.vercel.app/src/components/modal/test/basic?ionic:mode=ios - MD: https://ionic-framework-git-fw-7136-ionic1.vercel.app/src/components/modal/test/basic?ionic:mode=md Example iOS card modal safe-area: - iOS: https://ionic-framework-git-fw-7136-ionic1.vercel.app/src/components/modal/test/safe-area?ionic:mode=ios ## Dev Build: ``` 8.8.5-dev.11776879142.101200b9 ```
1 parent 0db5b40 commit f3cd39b

4 files changed

Lines changed: 218 additions & 54 deletions

File tree

core/src/components/content/content.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464

6565
.inner-scroll {
6666
@include position(calc(var(--offset-top) * -1), 0px,calc(var(--offset-bottom) * -1), 0px);
67-
@include padding(calc(var(--padding-top) + var(--offset-top)), var(--padding-end), calc(var(--padding-bottom) + var(--keyboard-offset) + var(--offset-bottom)), var(--padding-start));
67+
@include padding(calc(var(--padding-top) + var(--offset-top)), var(--padding-end), calc(var(--padding-bottom) + var(--keyboard-offset) + var(--offset-bottom) + var(--ion-content-safe-area-padding-bottom, 0px)), var(--padding-start));
6868

6969
position: absolute;
7070

core/src/components/modal/modal.tsx

Lines changed: 73 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
applySafeAreaOverrides,
5151
clearSafeAreaOverrides,
5252
getRootSafeAreaTop,
53+
hasCustomModalDimensions,
5354
type ModalSafeAreaContext,
5455
} from './safe-area-utils';
5556
import { setCardStatusBarDark, setCardStatusBarDefault } from './utils';
@@ -311,12 +312,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
311312
if (!context.isSheetModal && !context.isCardModal) {
312313
this.updateSafeAreaOverrides();
313314

314-
// Re-evaluate fullscreen safe-area padding: clear first, then re-apply
315-
if (this.wrapperEl) {
316-
this.wrapperEl.style.removeProperty('height');
317-
this.wrapperEl.style.removeProperty('padding-bottom');
318-
}
319-
this.applyFullscreenSafeArea();
315+
// Re-evaluate fullscreen safe-area padding: clear first, then re-apply.
316+
const { contentEl, hasFooter } = this.findContentAndFooter();
317+
this.clearContentSafeAreaPadding(contentEl);
318+
this.applyFullscreenSafeAreaTo(contentEl, hasFooter);
320319
}
321320
}, 50); // Debounce to avoid excessive calls during active resizing
322321
}
@@ -1429,6 +1428,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
14291428

14301429
/**
14311430
* Creates the context object for safe-area utilities.
1431+
*
1432+
* `hasCustomDimensions` is only set by `setInitialSafeAreaOverrides()`
1433+
* because it is only read by `getInitialSafeAreaConfig()`. Other callers
1434+
* (resize handler, post-animation update, fullscreen-padding apply) would
1435+
* pay a `getComputedStyle()` cost for a value they never consult.
14321436
*/
14331437
private getSafeAreaContext(): ModalSafeAreaContext {
14341438
return {
@@ -1451,7 +1455,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
14511455
* sheets to prevent header content from getting double-offset padding).
14521456
*/
14531457
private setInitialSafeAreaOverrides(): void {
1454-
const context = this.getSafeAreaContext();
1458+
const context: ModalSafeAreaContext = {
1459+
...this.getSafeAreaContext(),
1460+
hasCustomDimensions: hasCustomModalDimensions(this.el),
1461+
};
14551462
const safeAreaConfig = getInitialSafeAreaConfig(context);
14561463
applySafeAreaOverrides(this.el, safeAreaConfig);
14571464

@@ -1496,59 +1503,86 @@ export class Modal implements ComponentInterface, OverlayInterface {
14961503
}
14971504

14981505
/**
1499-
* Applies padding-bottom to fullscreen modal wrapper to prevent
1500-
* content from overlapping system navigation bar.
1506+
* Applies safe-area-bottom scroll padding to ion-content inside
1507+
* fullscreen modals that have no ion-footer. This prevents content
1508+
* from being hidden behind the system navigation bar while keeping
1509+
* the modal background edge-to-edge (no visible gap).
15011510
*/
15021511
private applyFullscreenSafeArea(): void {
1503-
const { wrapperEl, el } = this;
1504-
if (!wrapperEl) return;
1505-
15061512
const context = this.getSafeAreaContext();
15071513
if (context.isSheetModal || context.isCardModal) return;
15081514

1509-
// Check for standard Ionic layout children (ion-content, ion-footer),
1510-
// searching one level deep for wrapped components (e.g.,
1511-
// <app-footer><ion-footer>...</ion-footer></app-footer>).
1512-
// Note: uses a manual loop instead of querySelector(':scope > ...') because
1513-
// Stencil's mock-doc (used in spec tests) does not support :scope.
1514-
let hasContent = false;
1515+
const { contentEl, hasFooter } = this.findContentAndFooter();
1516+
this.applyFullscreenSafeAreaTo(contentEl, hasFooter);
1517+
}
1518+
1519+
/**
1520+
* Sets --ion-content-safe-area-padding-bottom on the given ion-content
1521+
* when no footer is present, so ion-content's .inner-scroll includes
1522+
* safe-area-bottom in its scroll padding. This keeps the modal background
1523+
* edge-to-edge while ensuring content scrolls clear of the system nav bar.
1524+
*
1525+
* --ion-content-safe-area-padding-bottom is an internal CSS property used
1526+
* only by this code path. It is not part of ion-content's public API and
1527+
* should not be set by consumers. The default of 0px makes it a no-op
1528+
* when unset, which is the expected state for ion-content used outside of
1529+
* a fullscreen modal without a footer.
1530+
*/
1531+
private applyFullscreenSafeAreaTo(contentEl: HTMLElement | null, hasFooter: boolean): void {
1532+
// Only apply for standard Ionic layouts (has ion-content but no
1533+
// ion-footer). When a footer is present it handles its own safe-area
1534+
// padding. Custom modals with raw HTML are developer-controlled.
1535+
if (!contentEl || hasFooter) return;
1536+
1537+
contentEl.style.setProperty('--ion-content-safe-area-padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
1538+
}
1539+
1540+
/**
1541+
* Removes the internal --ion-content-safe-area-padding-bottom property
1542+
* from an already-located ion-content. Callers do their own
1543+
* findContentAndFooter() so they can also read hasFooter if needed.
1544+
*/
1545+
private clearContentSafeAreaPadding(contentEl: HTMLElement | null): void {
1546+
if (!contentEl) return;
1547+
contentEl.style.removeProperty('--ion-content-safe-area-padding-bottom');
1548+
}
1549+
1550+
/**
1551+
* Finds ion-content and ion-footer among direct children and one level of
1552+
* grandchildren (for wrapped components like <app-footer><ion-footer>).
1553+
*
1554+
* Intentionally does NOT use findIonContent() or querySelector() because
1555+
* those search the full subtree and would match ion-content inside nested
1556+
* routes/pages. We only want direct slot children (+ one wrapper level).
1557+
*
1558+
* Uses a manual loop instead of querySelector(':scope > ...') because
1559+
* Stencil's mock-doc (used in spec tests) does not support :scope.
1560+
*/
1561+
private findContentAndFooter(): { contentEl: HTMLElement | null; hasFooter: boolean } {
1562+
let contentEl: HTMLElement | null = null;
15151563
let hasFooter = false;
1516-
for (const child of Array.from(el.children)) {
1517-
if (child.tagName === 'ION-CONTENT') hasContent = true;
1564+
for (const child of Array.from(this.el.children)) {
1565+
if (child.tagName === 'ION-CONTENT') contentEl = child as HTMLElement;
15181566
if (child.tagName === 'ION-FOOTER') hasFooter = true;
15191567
for (const grandchild of Array.from(child.children)) {
1520-
if (grandchild.tagName === 'ION-CONTENT') hasContent = true;
1568+
if (grandchild.tagName === 'ION-CONTENT' && !contentEl) contentEl = grandchild as HTMLElement;
15211569
if (grandchild.tagName === 'ION-FOOTER') hasFooter = true;
15221570
}
15231571
}
1524-
1525-
// Only apply wrapper padding for standard Ionic layouts (has ion-content
1526-
// but no ion-footer). Custom modals with raw HTML are fully
1527-
// developer-controlled and should not be modified.
1528-
if (!hasContent || hasFooter) return;
1529-
1530-
// Reduce wrapper height by safe-area and add equivalent padding so the
1531-
// total visual size stays the same but the flex content area shrinks.
1532-
// Using height + padding instead of box-sizing: border-box avoids
1533-
// breaking custom modals that set --border-width (border-box would
1534-
// include the border inside the height, changing the layout).
1535-
wrapperEl.style.setProperty('height', 'calc(var(--height) - var(--ion-safe-area-bottom, 0px))');
1536-
wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
1572+
return { contentEl, hasFooter };
15371573
}
15381574

15391575
/**
1540-
* Clears all safe-area overrides and padding from wrapper.
1576+
* Clears all safe-area overrides and padding.
15411577
*/
15421578
private cleanupSafeAreaOverrides(): void {
15431579
clearSafeAreaOverrides(this.el);
15441580

15451581
// Remove internal sheet offset property
15461582
this.el.style.removeProperty('--ion-modal-offset-top');
15471583

1548-
if (this.wrapperEl) {
1549-
this.wrapperEl.style.removeProperty('height');
1550-
this.wrapperEl.style.removeProperty('padding-bottom');
1551-
}
1584+
const { contentEl } = this.findContentAndFooter();
1585+
this.clearContentSafeAreaPadding(contentEl);
15521586
}
15531587

15541588
render() {

core/src/components/modal/safe-area-utils.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ export interface ModalSafeAreaContext {
2323
presentingElement?: HTMLElement;
2424
breakpoints?: number[];
2525
currentBreakpoint?: number;
26+
/**
27+
* Only consulted by `getInitialSafeAreaConfig()`. Callers that only use the
28+
* context for non-initial paths can omit this. See `hasCustomModalDimensions()`.
29+
*/
30+
hasCustomDimensions?: boolean;
2631
}
2732

2833
/**
@@ -38,6 +43,13 @@ const MODAL_INSET_MIN_WIDTH = 768;
3843
const MODAL_INSET_MIN_HEIGHT = 600;
3944
const EDGE_THRESHOLD = 5;
4045

46+
/**
47+
* CSS values for `--width` / `--height` that are treated as fullscreen
48+
* (modal touches the corresponding screen edges). Empty string means the
49+
* property was not overridden. See `hasCustomModalDimensions()`.
50+
*/
51+
const FULLSCREEN_SIZE_VALUES = new Set(['', '100%', '100vw', '100vh', '100dvw', '100dvh', '100svw', '100svh']);
52+
4153
/**
4254
* Cache for resolved root safe-area-top value, invalidated once per frame.
4355
*/
@@ -92,6 +104,23 @@ export const getRootSafeAreaTop = (): number => {
92104
return value;
93105
};
94106

107+
/**
108+
* True when the modal host declares BOTH a non-fullscreen `--width` AND a
109+
* non-fullscreen `--height` (i.e. a centered-dialog-like modal that doesn't
110+
* touch any screen edge).
111+
*
112+
* The conservative "both axes" check avoids mis-zeroing safe-area for
113+
* partial-custom modals where the modal still touches top/bottom edges
114+
* (e.g. only `--width` overridden). Partial cases fall through to the
115+
* existing position-based post-animation correction.
116+
*/
117+
export const hasCustomModalDimensions = (hostEl: HTMLElement): boolean => {
118+
const styles = getComputedStyle(hostEl);
119+
const width = styles.getPropertyValue('--width').trim();
120+
const height = styles.getPropertyValue('--height').trim();
121+
return !FULLSCREEN_SIZE_VALUES.has(width) && !FULLSCREEN_SIZE_VALUES.has(height);
122+
};
123+
95124
/**
96125
* Returns the initial safe-area configuration based on modal type.
97126
* This is called before animation starts and uses configuration-based prediction.
@@ -129,8 +158,11 @@ export const getInitialSafeAreaConfig = (context: ModalSafeAreaContext): SafeAre
129158

130159
// On viewports that meet the centered dialog media query breakpoints,
131160
// regular modals render as centered dialogs (not fullscreen), so they
132-
// don't touch any screen edges and don't need safe-area insets.
133-
if (isCenteredDialogViewport()) {
161+
// don't touch any screen edges and don't need safe-area insets. Also
162+
// applies to phone viewports when the modal declares custom --width and
163+
// --height; these don't touch screen edges either, so the initial
164+
// prediction must be zero to avoid a post-animation correction flash.
165+
if (isCenteredDialogViewport() || context.hasCustomDimensions) {
134166
return {
135167
top: '0px',
136168
bottom: '0px',

0 commit comments

Comments
 (0)