Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/src/components/content/content.scss
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@

.inner-scroll {
@include position(calc(var(--offset-top) * -1), 0px,calc(var(--offset-bottom) * -1), 0px);
@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));
@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));

position: absolute;

Expand Down
112 changes: 73 additions & 39 deletions core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
applySafeAreaOverrides,
clearSafeAreaOverrides,
getRootSafeAreaTop,
hasCustomModalDimensions,
type ModalSafeAreaContext,
} from './safe-area-utils';
import { setCardStatusBarDark, setCardStatusBarDefault } from './utils';
Expand Down Expand Up @@ -311,12 +312,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
if (!context.isSheetModal && !context.isCardModal) {
this.updateSafeAreaOverrides();

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

/**
* Creates the context object for safe-area utilities.
*
* `hasCustomDimensions` is only set by `setInitialSafeAreaOverrides()`
* because it is only read by `getInitialSafeAreaConfig()`. Other callers
* (resize handler, post-animation update, fullscreen-padding apply) would
* pay a `getComputedStyle()` cost for a value they never consult.
*/
private getSafeAreaContext(): ModalSafeAreaContext {
return {
Expand All @@ -1451,7 +1455,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
* sheets to prevent header content from getting double-offset padding).
*/
private setInitialSafeAreaOverrides(): void {
const context = this.getSafeAreaContext();
const context: ModalSafeAreaContext = {
...this.getSafeAreaContext(),
hasCustomDimensions: hasCustomModalDimensions(this.el),
};
const safeAreaConfig = getInitialSafeAreaConfig(context);
applySafeAreaOverrides(this.el, safeAreaConfig);

Expand Down Expand Up @@ -1496,59 +1503,86 @@ export class Modal implements ComponentInterface, OverlayInterface {
}

/**
* Applies padding-bottom to fullscreen modal wrapper to prevent
* content from overlapping system navigation bar.
* Applies safe-area-bottom scroll padding to ion-content inside
* fullscreen modals that have no ion-footer. This prevents content
* from being hidden behind the system navigation bar while keeping
* the modal background edge-to-edge (no visible gap).
*/
private applyFullscreenSafeArea(): void {
const { wrapperEl, el } = this;
if (!wrapperEl) return;

const context = this.getSafeAreaContext();
if (context.isSheetModal || context.isCardModal) return;

// Check for standard Ionic layout children (ion-content, ion-footer),
// searching one level deep for wrapped components (e.g.,
// <app-footer><ion-footer>...</ion-footer></app-footer>).
// Note: uses a manual loop instead of querySelector(':scope > ...') because
// Stencil's mock-doc (used in spec tests) does not support :scope.
let hasContent = false;
const { contentEl, hasFooter } = this.findContentAndFooter();
this.applyFullscreenSafeAreaTo(contentEl, hasFooter);
}

/**
* Sets --ion-content-safe-area-padding-bottom on the given ion-content
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if I'm wrong but it seems that --ion-content-safe-area-padding-bottom is a a new variable to the project? If that's the case, can we comment that it's meant for internal usages and when to use it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For sure! Done: 559103a

* when no footer is present, so ion-content's .inner-scroll includes
* safe-area-bottom in its scroll padding. This keeps the modal background
* edge-to-edge while ensuring content scrolls clear of the system nav bar.
*
* --ion-content-safe-area-padding-bottom is an internal CSS property used
* only by this code path. It is not part of ion-content's public API and
* should not be set by consumers. The default of 0px makes it a no-op
* when unset, which is the expected state for ion-content used outside of
* a fullscreen modal without a footer.
*/
private applyFullscreenSafeAreaTo(contentEl: HTMLElement | null, hasFooter: boolean): void {
// Only apply for standard Ionic layouts (has ion-content but no
// ion-footer). When a footer is present it handles its own safe-area
// padding. Custom modals with raw HTML are developer-controlled.
if (!contentEl || hasFooter) return;

contentEl.style.setProperty('--ion-content-safe-area-padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
}

/**
* Removes the internal --ion-content-safe-area-padding-bottom property
* from an already-located ion-content. Callers do their own
* findContentAndFooter() so they can also read hasFooter if needed.
*/
private clearContentSafeAreaPadding(contentEl: HTMLElement | null): void {
if (!contentEl) return;
contentEl.style.removeProperty('--ion-content-safe-area-padding-bottom');
}

/**
* Finds ion-content and ion-footer among direct children and one level of
* grandchildren (for wrapped components like <app-footer><ion-footer>).
*
* Intentionally does NOT use findIonContent() or querySelector() because
* those search the full subtree and would match ion-content inside nested
* routes/pages. We only want direct slot children (+ one wrapper level).
*
* Uses a manual loop instead of querySelector(':scope > ...') because
* Stencil's mock-doc (used in spec tests) does not support :scope.
*/
private findContentAndFooter(): { contentEl: HTMLElement | null; hasFooter: boolean } {
let contentEl: HTMLElement | null = null;
let hasFooter = false;
for (const child of Array.from(el.children)) {
if (child.tagName === 'ION-CONTENT') hasContent = true;
for (const child of Array.from(this.el.children)) {
if (child.tagName === 'ION-CONTENT') contentEl = child as HTMLElement;
if (child.tagName === 'ION-FOOTER') hasFooter = true;
for (const grandchild of Array.from(child.children)) {
if (grandchild.tagName === 'ION-CONTENT') hasContent = true;
if (grandchild.tagName === 'ION-CONTENT' && !contentEl) contentEl = grandchild as HTMLElement;
if (grandchild.tagName === 'ION-FOOTER') hasFooter = true;
}
}

// Only apply wrapper padding for standard Ionic layouts (has ion-content
// but no ion-footer). Custom modals with raw HTML are fully
// developer-controlled and should not be modified.
if (!hasContent || hasFooter) return;

// Reduce wrapper height by safe-area and add equivalent padding so the
// total visual size stays the same but the flex content area shrinks.
// Using height + padding instead of box-sizing: border-box avoids
// breaking custom modals that set --border-width (border-box would
// include the border inside the height, changing the layout).
wrapperEl.style.setProperty('height', 'calc(var(--height) - var(--ion-safe-area-bottom, 0px))');
wrapperEl.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
return { contentEl, hasFooter };
}

/**
* Clears all safe-area overrides and padding from wrapper.
* Clears all safe-area overrides and padding.
*/
private cleanupSafeAreaOverrides(): void {
clearSafeAreaOverrides(this.el);

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

if (this.wrapperEl) {
this.wrapperEl.style.removeProperty('height');
this.wrapperEl.style.removeProperty('padding-bottom');
}
const { contentEl } = this.findContentAndFooter();
this.clearContentSafeAreaPadding(contentEl);
}

render() {
Expand Down
36 changes: 34 additions & 2 deletions core/src/components/modal/safe-area-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export interface ModalSafeAreaContext {
presentingElement?: HTMLElement;
breakpoints?: number[];
currentBreakpoint?: number;
/**
* Only consulted by `getInitialSafeAreaConfig()`. Callers that only use the
* context for non-initial paths can omit this. See `hasCustomModalDimensions()`.
*/
hasCustomDimensions?: boolean;
}

/**
Expand All @@ -38,6 +43,13 @@ const MODAL_INSET_MIN_WIDTH = 768;
const MODAL_INSET_MIN_HEIGHT = 600;
const EDGE_THRESHOLD = 5;

/**
* CSS values for `--width` / `--height` that are treated as fullscreen
* (modal touches the corresponding screen edges). Empty string means the
* property was not overridden. See `hasCustomModalDimensions()`.
*/
const FULLSCREEN_SIZE_VALUES = new Set(['', '100%', '100vw', '100vh', '100dvw', '100dvh', '100svw', '100svh']);

/**
* Cache for resolved root safe-area-top value, invalidated once per frame.
*/
Expand Down Expand Up @@ -92,6 +104,23 @@ export const getRootSafeAreaTop = (): number => {
return value;
};

/**
* True when the modal host declares BOTH a non-fullscreen `--width` AND a
* non-fullscreen `--height` (i.e. a centered-dialog-like modal that doesn't
* touch any screen edge).
*
* The conservative "both axes" check avoids mis-zeroing safe-area for
* partial-custom modals where the modal still touches top/bottom edges
* (e.g. only `--width` overridden). Partial cases fall through to the
* existing position-based post-animation correction.
*/
export const hasCustomModalDimensions = (hostEl: HTMLElement): boolean => {
const styles = getComputedStyle(hostEl);
const width = styles.getPropertyValue('--width').trim();
const height = styles.getPropertyValue('--height').trim();
return !FULLSCREEN_SIZE_VALUES.has(width) && !FULLSCREEN_SIZE_VALUES.has(height);
};

/**
* Returns the initial safe-area configuration based on modal type.
* This is called before animation starts and uses configuration-based prediction.
Expand Down Expand Up @@ -129,8 +158,11 @@ export const getInitialSafeAreaConfig = (context: ModalSafeAreaContext): SafeAre

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