Compare commits

...

50 Commits

Author SHA1 Message Date
ionitron
1e4e9b9ff8 chore(): add updated snapshots 2026-02-03 21:23:00 +00:00
ShaneK
9097a1d146 Trying to improve accuracy 2026-02-03 13:11:38 -08:00
ShaneK
edc202db34 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-02-03 12:07:20 -08:00
ShaneK
1d232c8202 fix(modal): attempting to fix scenarios where the modal would become fully constrained 2026-02-03 10:31:00 -08:00
ShaneK
7584e617f1 chore(test): resetting screenshot test for irrelevant issue 2026-02-03 07:52:50 -08:00
ShaneK
fdb24e4f5f Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-02-02 06:00:49 -08:00
ShaneK
bc5b3a3a84 fix(popover): apply safe area adjustments for edge positioning in md mode 2026-02-02 06:00:40 -08:00
ionitron
b943db479e chore(): add updated snapshots 2026-01-30 16:58:29 +00:00
ShaneK
04c10985b1 Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-30 08:47:04 -08:00
ShaneK
ab7c863e36 test(safe-area): adding left/right safe area tests 2026-01-30 08:46:49 -08:00
ionitron
1895f8fb20 chore(): add updated snapshots 2026-01-30 16:28:49 +00:00
ShaneK
d4f646104b chore(tests): resetting screenshots to remove pointless diffs 2026-01-30 08:17:34 -08:00
ionitron
82584aaf83 chore(): add updated snapshots 2026-01-30 16:12:27 +00:00
ShaneK
3f1e2c0644 Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-30 08:00:45 -08:00
ShaneK
fcc50d6d16 fix(modal): fixing safe area in certain situations, adding some tests 2026-01-30 07:01:34 -08:00
ShaneK
1f68ad48f2 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-29 10:09:29 -08:00
ionitron
3bbb0a78f3 chore(): add updated snapshots 2026-01-14 18:19:42 +00:00
ShaneK
4d81e2d820 Resetting unnecessary screenshot changes 2026-01-14 10:08:14 -08:00
ShaneK
e1388e646a Merge branch 'FW-6830' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-14 10:04:54 -08:00
ShaneK
56190b2c79 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-14 10:04:39 -08:00
ionitron
d8abf4ce35 chore(): add updated snapshots 2026-01-12 17:43:04 +00:00
ShaneK
8ee1069b93 Reverting changes to be focused on modal and popover safe area support 2026-01-12 09:28:49 -08:00
ShaneK
3b7beca8d0 chore(lint): ignore disallowed property in content.scss 2026-01-09 11:31:51 -08:00
ionitron
0174a3938c chore(): add updated snapshots 2026-01-09 19:08:34 +00:00
ShaneK
f9159e1e90 fix(content): support side safe area content 2026-01-09 10:40:57 -08:00
ShaneK
095b72ef30 fix(content): detect dynamic tab bar changes for safe-area handling 2026-01-09 09:26:46 -08:00
ShaneK
e953f7b506 chore(tests): fix safe-area tests for Mobile Firefox 2026-01-08 10:14:21 -08:00
ShaneK
a63afa3db6 fix(modal): addressing edge cases, cleaning up 2026-01-08 09:02:51 -08:00
ShaneK
26b6b7bb02 fix(content): exclude nested content from safe-area handling 2026-01-08 06:59:16 -08:00
ShaneK
553aa65376 chore(tests): fixing tests having issues with mutation observers 2026-01-07 09:28:53 -08:00
ionitron
a5bd1dd518 chore(): add updated snapshots 2026-01-07 16:24:35 +00:00
ShaneK
48e4bc4776 fix(content): detect header/footer wrapped in custom components 2026-01-07 06:33:33 -08:00
ShaneK
fc496043d8 chore(test): zero out safe-area insets in test environments 2026-01-06 09:48:49 -08:00
ShaneK
4fe98a42ff fix(content): apply safe-area insets when header/footer absent 2026-01-06 08:41:31 -08:00
ShaneK
7c197c2c99 fix(popover): extending safe are protections to top/bottom overlap 2026-01-05 13:17:55 -08:00
ShaneK
4a165bc26c fix(modal): correct safe-area handling for MD mode and edge detection 2026-01-05 11:25:45 -08:00
ShaneK
9c404a6839 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2026-01-05 10:33:41 -08:00
ShaneK
39b15cb3b0 chore: fixing phone viewport tests 2026-01-02 08:15:39 -08:00
ShaneK
fa16c3a7bd chore: test fix 2026-01-02 07:00:32 -08:00
ShaneK
d6eb8ce8e9 fix(modal): apply safe-area padding to card modals on phones 2026-01-02 06:47:40 -08:00
ionitron
3fac5ccbf8 chore(): add updated snapshots 2025-12-31 21:05:04 +00:00
ShaneK
35579250d5 fix(modal): dynamically handle safe-area insets for edge-to-edge mode 2025-12-31 10:28:09 -08:00
ShaneK
61b588c6b9 Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2025-12-31 05:31:53 -08:00
ShaneK
4b7f2fadef Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-6830 2025-12-26 13:51:44 -08:00
ShaneK
415245b9b4 resetting unchanged snapshot 2025-12-26 11:01:53 -08:00
ionitron
61dc7eb4f0 chore(): add updated snapshots 2025-12-26 18:57:09 +00:00
ionitron
b87cd07e91 chore(): add updated snapshots 2025-12-26 18:25:42 +00:00
ShaneK
fea0a3da0f fix(modal): dynamically handle safe-area insets based on modal type and position 2025-12-26 10:15:02 -08:00
ShaneK
f66c84a9b9 fix(modal): dynamically apply safe-area insets based on viewport edge contact 2025-12-26 09:57:44 -08:00
ShaneK
c54f257633 fix(modal): respect safe area insets on tablet-sized screens 2025-12-23 14:28:12 -08:00
74 changed files with 1475 additions and 78 deletions

View File

@@ -52,7 +52,8 @@ export const createSheetGesture = (
expandToScroll: boolean,
getCurrentBreakpoint: () => number,
onDismiss: () => void,
onBreakpointChange: (breakpoint: number) => void
onBreakpointChange: (breakpoint: number) => void,
onGestureMove?: () => void
) => {
// Defaults for the sheet swipe animation
const defaultBackdrop = [
@@ -423,6 +424,9 @@ export const createSheetGesture = (
offset = clamp(0.0001, processedStep, maxStep);
animation.progressStep(offset);
// Notify modal of position change for safe-area updates
onGestureMove?.();
};
const onEnd = (detail: GestureDetail) => {

View File

@@ -20,7 +20,8 @@ export const createSwipeToCloseGesture = (
el: HTMLIonModalElement,
animation: Animation,
statusBarStyle: StatusBarStyle,
onDismiss: () => void
onDismiss: () => void,
onGestureMove?: () => void
) => {
/**
* The step value at which a card modal
@@ -199,6 +200,9 @@ export const createSwipeToCloseGesture = (
animation.progressStep(clampedStep);
// Notify modal of position change for safe-area updates
onGestureMove?.();
/**
* When swiping down half way, the status bar style
* should be reset to its default value.

View File

@@ -94,10 +94,6 @@ ion-backdrop {
:host {
--width: #{$modal-inset-width};
--height: #{$modal-inset-height-small};
--ion-safe-area-top: 0px;
--ion-safe-area-bottom: 0px;
--ion-safe-area-right: 0px;
--ion-safe-area-left: 0px;
}
}

View File

@@ -1,5 +1,6 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h, writeTask } from '@stencil/core';
import { win } from '@utils/browser';
import { findIonContent, printIonContentErrorMsg } from '@utils/content';
import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate';
import { raf, inheritAttributes, hasLazyBuild, getElementRoot } from '@utils/helpers';
@@ -74,6 +75,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
@State() private isSheetModal = false;
private currentBreakpoint?: number;
private wrapperEl?: HTMLElement;
private shadowEl?: HTMLElement;
private backdropEl?: HTMLIonBackdropElement;
private dragHandleEl?: HTMLButtonElement;
private sortedBreakpoints?: number[];
@@ -98,10 +100,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;
@@ -276,7 +286,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
@Listen('resize', { target: 'window' })
onWindowResize() {
// Only handle resize for iOS card modals when no custom animations are provided
// 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
if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
return;
}
@@ -406,6 +420,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.triggerController.removeClickListener();
this.cleanupViewTransitionListener();
this.cleanupParentRemovalObserver();
// Reset safe-area state to handle removal without dismiss (e.g., framework unmount)
this.resetSafeAreaState();
}
componentWillLoad() {
@@ -592,6 +608,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
await waitForMount();
}
// Predict safe-area needs based on modal configuration to avoid visual snap
this.setInitialSafeAreaOverrides(presentingElement);
writeTask(() => this.el.classList.add('show-modal'));
const hasCardModal = presentingElement !== undefined;
@@ -659,6 +678,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.initSwipeToClose();
}
// Now that animation is complete, update safe-area based on actual position
this.updateSafeAreaOverrides();
// Initialize view transition listener for iOS card modals
this.initViewTransitionListener();
@@ -692,33 +714,39 @@ export class Modal implements ComponentInterface, OverlayInterface {
const statusBarStyle = this.statusBarStyle ?? StatusBarStyle.Default;
this.gesture = createSwipeToCloseGesture(el, ani, statusBarStyle, () => {
/**
* While the gesture animation is finishing
* it is possible for a user to tap the backdrop.
* This would result in the dismiss animation
* being played again. Typically this is avoided
* by setting `presented = false` on the overlay
* component; however, we cannot do that here as
* that would prevent the element from being
* removed from the DOM.
*/
this.gestureAnimationDismissing = true;
this.gesture = createSwipeToCloseGesture(
el,
ani,
statusBarStyle,
() => {
/**
* While the gesture animation is finishing
* it is possible for a user to tap the backdrop.
* This would result in the dismiss animation
* being played again. Typically this is avoided
* by setting `presented = false` on the overlay
* component; however, we cannot do that here as
* that would prevent the element from being
* removed from the DOM.
*/
this.gestureAnimationDismissing = true;
/**
* Reset the status bar style as the dismiss animation
* starts otherwise the status bar will be the wrong
* color for the duration of the dismiss animation.
* The dismiss method does this as well, but
* in this case it's only called once the animation
* has finished.
*/
setCardStatusBarDefault(this.statusBarStyle);
this.animation!.onFinish(async () => {
await this.dismiss(undefined, GESTURE);
this.gestureAnimationDismissing = false;
});
});
/**
* Reset the status bar style as the dismiss animation
* starts otherwise the status bar will be the wrong
* color for the duration of the dismiss animation.
* The dismiss method does this as well, but
* in this case it's only called once the animation
* has finished.
*/
setCardStatusBarDefault(this.statusBarStyle);
this.animation!.onFinish(async () => {
await this.dismiss(undefined, GESTURE);
this.gestureAnimationDismissing = false;
});
},
() => this.updateSafeAreaOverrides()
);
this.gesture.enable(true);
}
@@ -755,7 +783,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.currentBreakpoint = breakpoint;
this.ionBreakpointDidChange.emit({ breakpoint });
}
}
this.updateSafeAreaOverrides();
},
() => this.updateSafeAreaOverrides()
);
this.gesture = gesture;
@@ -849,6 +879,212 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.cachedPageParent = undefined;
}
/**
* Sets initial safe-area overrides based on modal configuration before
* the modal becomes visible. This predicts whether the modal will touch
* screen edges to avoid a visual snap after animation completes.
*/
private setInitialSafeAreaOverrides(presentingElement: HTMLElement | undefined) {
const style = this.el.style;
const mode = getIonMode(this);
const isSheetModal = this.breakpoints !== undefined && this.initialBreakpoint !== undefined;
// Card modals only exist in iOS mode - in MD mode, presentingElement is ignored
const isCardModal = presentingElement !== undefined && mode === 'ios';
const isTablet = window.innerWidth >= 768;
// Sheet modals always touch bottom edge, never top/left/right
if (isSheetModal) {
style.setProperty('--ion-safe-area-top', '0px');
style.setProperty('--ion-safe-area-left', '0px');
style.setProperty('--ion-safe-area-right', '0px');
return;
}
// Card modals have rounded top corners
if (isCardModal) {
style.setProperty('--ion-safe-area-top', '0px');
if (isTablet) {
// On tablets, card modals are inset from all edges
this.zeroAllSafeAreas();
} else {
// On phones, card modals still extend to the bottom edge
style.setProperty('--ion-safe-area-left', '0px');
style.setProperty('--ion-safe-area-right', '0px');
this.applyFullscreenSafeArea();
}
return;
}
// Check if modal is fullscreen via CSS custom properties
// This applies to both phone and tablet sizes - custom modals may have
// non-fullscreen dimensions even on phones (e.g., --height: 70%)
const computedStyle = getComputedStyle(this.el);
const width = computedStyle.getPropertyValue('--width').trim();
const height = computedStyle.getPropertyValue('--height').trim();
const isFullscreen = width === '100%' && height === '100%';
if (isFullscreen) {
this.applyFullscreenSafeArea();
} else if (isTablet) {
// Centered dialog on tablet doesn't touch edges
this.zeroAllSafeAreas();
} else {
// Non-fullscreen modal on phone - use coordinate-based detection
// to determine which edges it touches (e.g., bottom-aligned custom modals)
}
}
/**
* Applies safe-area handling for fullscreen modals.
* Adds wrapper padding when no footer is present to prevent
* content from overlapping system navigation areas.
*/
private applyFullscreenSafeArea() {
this.skipSafeAreaCoordinateDetection = true;
this.updateFooterPadding();
// Watch for dynamic footer additions/removals (e.g., async data loading)
// Use subtree:true to support wrapped footers in framework components
// (e.g., <my-footer><ion-footer>...</ion-footer></my-footer>)
if (!this.footerObserver && win !== undefined && 'MutationObserver' in win) {
this.footerObserver = new MutationObserver(() => this.updateFooterPadding());
this.footerObserver.observe(this.el, { childList: true, subtree: true });
}
}
/**
* Updates wrapper and shadow padding based on footer presence.
* Called initially and when footer is dynamically added/removed.
* Both elements must be styled identically to prevent visual mismatches.
*/
private updateFooterPadding() {
if (!this.wrapperEl) return;
const hasFooter = this.el.querySelector('ion-footer') !== null;
// Apply to both wrapper and shadow to keep them in sync
const elements = [this.wrapperEl, this.shadowEl].filter(Boolean) as HTMLElement[];
if (hasFooter) {
elements.forEach((el) => {
el.style.removeProperty('padding-bottom');
el.style.removeProperty('box-sizing');
});
} else {
elements.forEach((el) => {
el.style.setProperty('padding-bottom', 'var(--ion-safe-area-bottom, 0px)');
el.style.setProperty('box-sizing', 'border-box');
});
}
}
/**
* Sets all safe-area CSS variables to 0px for modals that
* don't touch screen edges.
*/
private zeroAllSafeAreas() {
const style = this.el.style;
style.setProperty('--ion-safe-area-top', '0px');
style.setProperty('--ion-safe-area-bottom', '0px');
style.setProperty('--ion-safe-area-left', '0px');
style.setProperty('--ion-safe-area-right', '0px');
}
/**
* Resets all safe-area related state and styles.
* Called during dismiss and disconnectedCallback to ensure clean state
* for re-presentation of inline modals.
*/
private resetSafeAreaState() {
this.skipSafeAreaCoordinateDetection = false;
this.cachedSafeAreas = undefined;
this.prevSafeAreaState = { top: false, bottom: false, left: false, right: false };
this.footerObserver?.disconnect();
this.footerObserver = undefined;
// Clear wrapper and shadow styles that may have been set for safe-area handling
[this.wrapperEl, this.shadowEl].forEach((el) => {
if (el) {
el.style.removeProperty('padding-bottom');
el.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');
}
/**
* Gets the root safe-area values from the document element.
* Uses cached values during gestures to avoid getComputedStyle calls.
*/
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) {
return;
}
const wrapper = this.wrapperEl;
if (!wrapper) {
return;
}
const rect = wrapper.getBoundingClientRect();
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;
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() {
/**
* While the gesture animation is finishing
@@ -961,6 +1197,8 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
this.currentBreakpoint = undefined;
this.animation = undefined;
// Reset safe-area state for potential re-presentation
this.resetSafeAreaState();
unlock();
@@ -1385,7 +1623,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
part="backdrop"
/>
{mode === 'ios' && <div class="modal-shadow"></div>}
{mode === 'ios' && <div class="modal-shadow" ref={(el) => (this.shadowEl = el)}></div>}
<div
/*

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Modal - Safe Area</title>
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
/**
* Simulate safe-area insets for testing.
* Values represent combined scenarios: top/bottom from portrait devices,
* left/right from landscape orientation or devices with side notches.
*/
:root {
--ion-safe-area-top: 44px;
--ion-safe-area-bottom: 34px;
--ion-safe-area-left: 44px;
--ion-safe-area-right: 44px;
}
.fullscreen-modal {
--width: 100%;
--height: 100%;
}
/* Visual indicators for safe areas */
.safe-area-indicator {
position: fixed;
background: rgba(255, 0, 0, 0.2);
pointer-events: none;
z-index: 99999;
}
.safe-area-top {
top: 0;
left: 0;
right: 0;
height: var(--ion-safe-area-top);
}
.safe-area-bottom {
bottom: 0;
left: 0;
right: 0;
height: var(--ion-safe-area-bottom);
}
.safe-area-left {
top: 0;
bottom: 0;
left: 0;
width: var(--ion-safe-area-left);
}
.safe-area-right {
top: 0;
bottom: 0;
right: 0;
width: var(--ion-safe-area-right);
}
</style>
</head>
<body>
<!-- Visual indicators for safe areas (red overlay) -->
<div class="safe-area-indicator safe-area-top"></div>
<div class="safe-area-indicator safe-area-bottom"></div>
<div class="safe-area-indicator safe-area-left"></div>
<div class="safe-area-indicator safe-area-right"></div>
<ion-app>
<div class="ion-page" id="main-page">
<ion-header>
<ion-toolbar>
<ion-title>Modal - Safe Area</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>Test safe-area handling in modals. Red overlays indicate safe areas (top, bottom, left, right).</p>
<p>
<strong>Landscape simulation:</strong> Left and right safe areas are set to 44px to test devices with side
notches.
</p>
<ion-list>
<ion-item-group>
<ion-item-divider>
<ion-label>With Footer</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<h2>Default Modal</h2>
<p>Centered dialog on tablet - should NOT have safe-area padding</p>
</ion-label>
<ion-button slot="end" id="default-modal" onclick="presentDefaultModal()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Fullscreen Modal</h2>
<p>Full screen - footer handles safe-area</p>
</ion-label>
<ion-button slot="end" id="fullscreen-modal" onclick="presentFullscreenModal()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Sheet Modal (Partial)</h2>
<p>At 0.5 breakpoint - should have bottom safe-area only</p>
</ion-label>
<ion-button slot="end" id="sheet-modal-partial" onclick="presentSheetModalPartial()"
>Present</ion-button
>
</ion-item>
<ion-item>
<ion-label>
<h2>Sheet Modal (Full)</h2>
<p>At 1.0 breakpoint - should have bottom safe-area</p>
</ion-label>
<ion-button slot="end" id="sheet-modal-full" onclick="presentSheetModalFull()">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Card Modal (iOS)</h2>
<p>Card presentation with presentingElement</p>
</ion-label>
<ion-button slot="end" id="card-modal" onclick="presentCardModal()">Present</ion-button>
</ion-item>
</ion-item-group>
<ion-item-group>
<ion-item-divider>
<ion-label>Without Footer (wrapper padding)</ion-label>
</ion-item-divider>
<ion-item>
<ion-label>
<h2>Fullscreen Modal (no footer)</h2>
<p>Wrapper padding should prevent content overlap</p>
</ion-label>
<ion-button slot="end" id="fullscreen-no-footer" onclick="presentFullscreenNoFooter()"
>Present</ion-button
>
</ion-item>
<ion-item>
<ion-label>
<h2>Card Modal (no footer)</h2>
<p>On phones, wrapper padding should prevent content overlap</p>
</ion-label>
<ion-button slot="end" id="card-modal-no-footer" onclick="presentCardModalNoFooter()"
>Present</ion-button
>
</ion-item>
<ion-item>
<ion-label>
<h2>Default Modal (no footer)</h2>
<p>On phones, wrapper padding should prevent content overlap</p>
</ion-label>
<ion-button slot="end" id="default-no-footer" onclick="presentDefaultNoFooter()">Present</ion-button>
</ion-item>
</ion-item-group>
</ion-list>
</ion-content>
</div>
</ion-app>
<script>
function createModalContent(title, includeFooter = true) {
const element = document.createElement('div');
const footerHtml = includeFooter
? `
<ion-footer>
<ion-toolbar>
<ion-title>Footer</ion-title>
</ion-toolbar>
</ion-footer>
`
: '';
// Create multiple items to ensure scrollable content
const items = Array.from(
{ length: 20 },
(_, i) => `
<ion-item>
<ion-label>Item ${i + 1}</ion-label>
</ion-item>
`
).join('');
element.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-title>${title}</ion-title>
<ion-buttons slot="end">
<ion-button class="dismiss" onclick="dismissModal()">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<h1>Modal Content</h1>
<p>This modal tests safe-area handling.</p>
<ion-list>
${items}
</ion-list>
<p class="last-item">Last item - should not overlap safe area</p>
</ion-content>
${footerHtml}
`;
return element;
}
let currentModal = null;
async function dismissModal() {
if (currentModal) {
await currentModal.dismiss();
currentModal = null;
}
}
async function presentDefaultModal() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Default Modal'),
});
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentFullscreenModal() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Fullscreen Modal'),
});
currentModal.classList.add('fullscreen-modal');
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentSheetModalPartial() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Sheet Modal (Partial)'),
initialBreakpoint: 0.5,
breakpoints: [0, 0.25, 0.5, 0.75, 1],
});
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentSheetModalFull() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Sheet Modal (Full)'),
initialBreakpoint: 1,
breakpoints: [0, 0.5, 1],
});
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentCardModal() {
const presentingElement = document.getElementById('main-page');
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Card Modal'),
presentingElement: presentingElement,
});
document.body.appendChild(currentModal);
await currentModal.present();
}
// Modals without footer - test wrapper padding
async function presentFullscreenNoFooter() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Fullscreen (No Footer)', false),
});
currentModal.classList.add('fullscreen-modal');
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentCardModalNoFooter() {
const presentingElement = document.getElementById('main-page');
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Card Modal (No Footer)', false),
presentingElement: presentingElement,
});
document.body.appendChild(currentModal);
await currentModal.present();
}
async function presentDefaultNoFooter() {
currentModal = Object.assign(document.createElement('ion-modal'), {
component: createModalContent('Default (No Footer)', false),
});
document.body.appendChild(currentModal);
await currentModal.present();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,321 @@
import { expect } from '@playwright/test';
import type { E2EPage } from '@utils/test/playwright';
import { configs, test, Viewports } from '@utils/test/playwright';
/**
* Safe-area tests verify that modals correctly handle safe-area insets
* based on modal type and screen size.
*
* These tests use simulated safe-area values set in index.html:
* - Top: 44px, Bottom: 34px, Left: 44px, Right: 44px
*
* The test HTML includes red visual indicators for all safe areas to
* verify modal content doesn't overlap unsafe regions.
*/
// Helper to get the modal wrapper's computed padding-bottom
async function getWrapperPaddingBottom(page: E2EPage): Promise<string> {
const modal = page.locator('ion-modal');
return modal.evaluate((el: HTMLIonModalElement) => {
const wrapper = el.shadowRoot?.querySelector('.modal-wrapper');
if (!wrapper) return '0px';
return getComputedStyle(wrapper).paddingBottom;
});
}
// Helper to check if modal has a footer
async function modalHasFooter(page: E2EPage): Promise<boolean> {
const modal = page.locator('ion-modal');
return modal.evaluate((el: HTMLIonModalElement) => {
return el.querySelector('ion-footer') !== null;
});
}
// Phone viewport (less than 768px width)
const PhoneViewport = { width: 390, height: 844 };
// =============================================================================
// Phone Tests - Fullscreen modals need wrapper padding when no footer
// =============================================================================
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - phone'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(PhoneViewport);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('fullscreen modal without footer should have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-no-footer');
await ionModalDidPresent.next();
const hasFooter = await modalHasFooter(page);
expect(hasFooter).toBe(false);
const paddingBottom = await getWrapperPaddingBottom(page);
// Should have safe-area padding (34px as set in test HTML)
expect(paddingBottom).toBe('34px');
});
test('fullscreen modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-modal');
await ionModalDidPresent.next();
const hasFooter = await modalHasFooter(page);
expect(hasFooter).toBe(true);
const paddingBottom = await getWrapperPaddingBottom(page);
// Footer handles safe-area, wrapper should have no padding
expect(paddingBottom).toBe('0px');
});
test('default modal without footer should have wrapper padding on phone', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#default-no-footer');
await ionModalDidPresent.next();
// On phones, default modals are fullscreen
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('34px');
});
});
});
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - card modal on phone'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(PhoneViewport);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('card modal without footer should have wrapper padding on phone', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal-no-footer');
await ionModalDidPresent.next();
// Card modals on phones still extend to bottom edge
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('34px');
});
test('card modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal');
await ionModalDidPresent.next();
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});
// =============================================================================
// Tablet Tests - Centered dialogs don't need safe-area, fullscreen does
// =============================================================================
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - tablet'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(Viewports.tablet.portrait);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('default modal should not have wrapper padding on tablet', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#default-modal');
await ionModalDidPresent.next();
// Centered dialog on tablet - inset from edges, no padding needed
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
test('fullscreen modal without footer should have wrapper padding on tablet', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-no-footer');
await ionModalDidPresent.next();
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('34px');
});
test('fullscreen modal with footer should not have wrapper padding', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-modal');
await ionModalDidPresent.next();
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - card modal on tablet'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(Viewports.tablet.portrait);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('card modal should not have wrapper padding on tablet', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal');
await ionModalDidPresent.next();
// Card modals on tablets are inset from all edges
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});
// =============================================================================
// Sheet Modal Tests - Always touch bottom edge
// =============================================================================
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('modal: safe-area - sheet modal'), () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize(Viewports.tablet.portrait);
await page.goto('/src/components/modal/test/safe-area', config);
});
test('sheet modal should not have wrapper padding (footer handles safe-area)', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://outsystemsrd.atlassian.net/browse/FW-6830',
});
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#sheet-modal-full');
await ionModalDidPresent.next();
// Sheet modals with footer - footer handles the safe area
const paddingBottom = await getWrapperPaddingBottom(page);
expect(paddingBottom).toBe('0px');
});
});
});
// Landscape viewport simulates devices with side notches or landscape orientation
const LandscapeViewport = { width: 844, height: 390 };
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('modal: safe-area screenshots'), () => {
test('fullscreen modal should not overlap safe areas in landscape', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/modal/test/safe-area', config);
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-modal');
await ionModalDidPresent.next();
// Red overlays show safe areas - modal content should not overlap them
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-fullscreen-landscape'));
});
test('fullscreen modal without footer should show wrapper padding in landscape', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/modal/test/safe-area', config);
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#fullscreen-no-footer');
await ionModalDidPresent.next();
// Without footer, wrapper padding prevents content from overlapping bottom safe area
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-fullscreen-no-footer-landscape'));
});
});
});
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('modal: safe-area screenshots - tablet'), () => {
test('centered dialog should be inset from all safe areas', async ({ page }) => {
await page.setViewportSize(Viewports.tablet.portrait);
await page.goto('/src/components/modal/test/safe-area', config);
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#default-modal');
await ionModalDidPresent.next();
// Centered dialog should not touch any edges or safe areas
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-centered-tablet'));
});
});
});
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('modal: safe-area screenshots - card modal'), () => {
test('card modal should handle safe areas correctly in landscape', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/modal/test/safe-area', config);
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#card-modal');
await ionModalDidPresent.next();
await expect(page).toHaveScreenshot(screenshot('modal-safe-area-card-landscape'));
});
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -11,6 +11,12 @@ import {
} from '../utils';
const POPOVER_IOS_BODY_PADDING = 5;
/**
* Extra margin around viewport edges for safe area detection.
* When popover is within this distance of an edge, safe area
* CSS variables will be applied to prevent overlap with system UI.
*/
const POPOVER_IOS_SAFE_AREA_MARGIN = 25;
/**
* iOS Popover Enter Animation
@@ -53,7 +59,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
);
const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
const margin = size === 'cover' ? 0 : 25;
const margin = size === 'cover' ? 0 : POPOVER_IOS_SAFE_AREA_MARGIN;
const {
originX,
@@ -61,11 +67,14 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
top,
left,
bottom,
checkSafeAreaTop,
checkSafeAreaBottom,
checkSafeAreaLeft,
checkSafeAreaRight,
arrowTop,
arrowLeft,
addPopoverBottomClass,
isFullyConstrained,
} = calculateWindowAdjustment(
side,
results.top,
@@ -84,8 +93,37 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
arrowHeight
);
/**
* Safe area CSS variable adjustments.
* When the popover is positioned near an edge, we add the corresponding
* safe-area inset to ensure the popover doesn't overlap with system UI
* (status bars, home indicators, navigation bars on Android API 36+, etc.)
*/
const safeAreaTop = ' + var(--ion-safe-area-top, 0px)';
const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0px)';
const safeAreaLeft = ' + var(--ion-safe-area-left, 0px)';
const safeAreaRight = ' - var(--ion-safe-area-right, 0px)';
let topValue = `${top}px`;
let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
let leftValue = `${left}px`;
if (checkSafeAreaTop) {
topValue = `${top}px${safeAreaTop}`;
}
if (checkSafeAreaBottom && bottomValue !== undefined) {
bottomValue = `${bottom}px${safeAreaBottom}`;
}
if (checkSafeAreaLeft) {
leftValue = `${left}px${safeAreaLeft}`;
}
if (checkSafeAreaRight) {
leftValue = `${left}px${safeAreaRight}`;
}
const baseAnimation = createAnimation();
const backdropAnimation = createAnimation();
const arrowAnimation = createAnimation();
const contentAnimation = createAnimation();
backdropAnimation
@@ -100,11 +138,42 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
// The Chromium team stated that this behavior is expected and not a bug. The element animating opacity creates a backdrop root for the backdrop-filter.
// To get around this, instead of animating the wrapper, animate both the arrow and content.
// https://bugs.chromium.org/p/chromium/issues/detail?id=1148826
contentAnimation
.addElement(root.querySelector('.popover-arrow')!)
.addElement(root.querySelector('.popover-content')!)
.fromTo('opacity', 0.01, 1);
// TODO(FW-4376) Ensure that arrow also blurs when translucent
if (arrowEl !== null) {
arrowAnimation.addElement(arrowEl).fromTo('opacity', 0.01, 1);
}
contentAnimation
.addElement(contentEl)
.beforeAddWrite(() => {
contentEl.style.setProperty('top', `calc(${topValue} + var(--offset-y, 0px))`);
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0px))`);
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
if (bottomValue !== undefined) {
contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
/**
* When both top and bottom are explicitly constrained (isFullyConstrained),
* we need to explicitly calculate the height to ensure the popover
* fits within the safe area boundaries.
*
* Using CSS calc with 100vh minus top and bottom values ensures the
* popover height respects both safe areas. We also override max-height
* to prevent it from interfering with the calculated height.
*/
if (isFullyConstrained) {
/**
* Wrap topValue and bottomValue in parentheses to ensure correct
* order of operations in the CSS calc. Without parentheses, the
* safe-area additions would have wrong signs.
*/
const heightCalc = `calc(100vh - (${topValue}) - (${bottomValue}) - var(--offset-y, 0px))`;
contentEl.style.setProperty('height', heightCalc);
contentEl.style.setProperty('max-height', heightCalc);
}
}
})
.fromTo('opacity', 0.01, 1);
return baseAnimation
.easing('ease')
@@ -118,37 +187,21 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
baseEl.classList.add('popover-bottom');
}
if (bottom !== undefined) {
contentEl.style.setProperty('bottom', `${bottom}px`);
}
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
let leftValue = `${left}px`;
if (checkSafeAreaLeft) {
leftValue = `${left}px${safeAreaLeft}`;
}
if (checkSafeAreaRight) {
leftValue = `${left}px${safeAreaRight}`;
}
contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
if (arrowEl !== null) {
const didAdjustBounds = results.top !== top || results.left !== left;
const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger);
/**
* Hide the arrow when the popover is fully constrained to the viewport
* because it cannot accurately point to the trigger in this case.
*/
const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger) && !isFullyConstrained;
if (showArrow) {
arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`);
arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0))`);
arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0px))`);
arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0px))`);
} else {
arrowEl.style.setProperty('display', 'none');
}
}
})
.addAnimation([backdropAnimation, contentAnimation]);
.addAnimation([backdropAnimation, arrowAnimation, contentAnimation]);
};

View File

@@ -5,6 +5,12 @@ import type { Animation } from '../../../interface';
import { calculateWindowAdjustment, getPopoverDimensions, getPopoverPosition } from '../utils';
const POPOVER_MD_BODY_PADDING = 12;
/**
* Extra margin around viewport edges for safe area detection.
* When popover is within this distance of an edge, safe area
* CSS variables will be applied to prevent overlap with system UI.
*/
const POPOVER_MD_SAFE_AREA_MARGIN = 25;
/**
* Md Popover Enter Animation
@@ -47,7 +53,20 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
const padding = size === 'cover' ? 0 : POPOVER_MD_BODY_PADDING;
const { originX, originY, top, left, bottom } = calculateWindowAdjustment(
const margin = size === 'cover' ? 0 : POPOVER_MD_SAFE_AREA_MARGIN;
const {
originX,
originY,
top,
left,
bottom,
checkSafeAreaTop,
checkSafeAreaBottom,
checkSafeAreaLeft,
checkSafeAreaRight,
isFullyConstrained,
} = calculateWindowAdjustment(
side,
results.top,
results.left,
@@ -56,12 +75,40 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
bodyHeight,
contentWidth,
contentHeight,
0,
margin,
results.originX,
results.originY,
results.referenceCoordinates
);
/**
* Safe area CSS variable adjustments.
* When the popover is positioned near an edge, we add the corresponding
* safe-area inset to ensure the popover doesn't overlap with system UI
* (status bars, home indicators, navigation bars on Android API 36+, etc.)
*/
const safeAreaTop = ' + var(--ion-safe-area-top, 0)';
const safeAreaBottom = ' + var(--ion-safe-area-bottom, 0)';
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
let topValue = `${top}px`;
let bottomValue = bottom !== undefined ? `${bottom}px` : undefined;
let leftValue = `${left}px`;
if (checkSafeAreaTop) {
topValue = `${top}px${safeAreaTop}`;
}
if (checkSafeAreaBottom && bottomValue !== undefined) {
bottomValue = `${bottom}px${safeAreaBottom}`;
}
if (checkSafeAreaLeft) {
leftValue = `${left}px${safeAreaLeft}`;
}
if (checkSafeAreaRight) {
leftValue = `${left}px${safeAreaRight}`;
}
const baseAnimation = createAnimation();
const backdropAnimation = createAnimation();
const wrapperAnimation = createAnimation();
@@ -81,13 +128,32 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation =>
contentAnimation
.addElement(contentEl)
.beforeStyles({
top: `calc(${top}px + var(--offset-y, 0px))`,
left: `calc(${left}px + var(--offset-x, 0px))`,
top: `calc(${topValue} + var(--offset-y, 0px))`,
left: `calc(${leftValue} + var(--offset-x, 0px))`,
'transform-origin': `${originY} ${originX}`,
})
.beforeAddWrite(() => {
if (bottom !== undefined) {
contentEl.style.setProperty('bottom', `${bottom}px`);
if (bottomValue !== undefined) {
contentEl.style.setProperty('bottom', `calc(${bottomValue})`);
/**
* When both top and bottom are explicitly constrained (isFullyConstrained),
* we need to explicitly calculate the height to ensure the popover
* fits within the safe area boundaries.
*
* Using CSS calc with 100vh minus top and bottom values ensures the
* popover height respects both safe areas. We also override max-height
* to prevent it from interfering with the calculated height.
*/
if (isFullyConstrained) {
/**
* Wrap topValue and bottomValue in parentheses to ensure correct
* order of operations in the CSS calc. Without parentheses, the
* safe-area additions would have wrong signs.
*/
const heightCalc = `calc(100vh - (${topValue}) - (${bottomValue}) - var(--offset-y, 0px))`;
contentEl.style.setProperty('height', heightCalc);
contentEl.style.setProperty('max-height', heightCalc);
}
}
})
.fromTo('transform', 'scale(0.8)', 'scale(1)');

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Popover - Safe Area</title>
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
/**
* Simulate safe-area insets for testing.
* These values represent typical Android edge-to-edge safe areas.
* Left/right values simulate landscape orientation or devices with side notches.
*/
:root {
--ion-safe-area-top: 44px;
--ion-safe-area-bottom: 34px;
--ion-safe-area-left: 44px;
--ion-safe-area-right: 44px;
}
/* Visual indicator for safe areas */
.safe-area-indicator {
position: fixed;
background: rgba(255, 0, 0, 0.2);
pointer-events: none;
z-index: 99999;
}
.safe-area-top {
top: 0;
left: 0;
right: 0;
height: var(--ion-safe-area-top);
}
.safe-area-bottom {
bottom: 0;
left: 0;
right: 0;
height: var(--ion-safe-area-bottom);
}
.safe-area-left {
top: 0;
bottom: 0;
left: 0;
width: var(--ion-safe-area-left);
}
.safe-area-right {
top: 0;
bottom: 0;
right: 0;
width: var(--ion-safe-area-right);
}
/* Position triggers at different locations */
.bottom-trigger {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
}
.near-bottom-trigger {
position: fixed;
bottom: 200px;
right: 20px;
}
</style>
</head>
<body>
<ion-app>
<!-- Visual indicators for safe areas -->
<div class="safe-area-indicator safe-area-top"></div>
<div class="safe-area-indicator safe-area-bottom"></div>
<div class="safe-area-indicator safe-area-left"></div>
<div class="safe-area-indicator safe-area-right"></div>
<div class="ion-page" id="main-page">
<ion-header>
<ion-toolbar>
<ion-title>Popover - Safe Area Positioning</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>Test that popovers are <strong>positioned away from</strong> unsafe areas (shown in red).</p>
<p>The popover should be moved up/down/left/right to avoid overlapping the safe-area zones.</p>
<p>
<strong>Landscape simulation:</strong> Left and right safe areas are set to 44px to test devices with side
notches.
</p>
<ion-list>
<ion-item>
<ion-label>
<h2>Small Popover (Center)</h2>
<p>Floating popover - positioned in center, no adjustment needed</p>
</ion-label>
<ion-button slot="end" id="small-popover-trigger">Present</ion-button>
</ion-item>
<ion-item>
<ion-label>
<h2>Large Popover</h2>
<p>Tall content that may extend toward bottom safe area</p>
</ion-label>
<ion-button slot="end" id="large-popover-trigger">Present</ion-button>
</ion-item>
</ion-list>
<ion-button class="bottom-trigger" id="bottom-trigger"> Trigger Near Bottom </ion-button>
<ion-button class="near-bottom-trigger" id="near-bottom-trigger"> Near Bottom Right </ion-button>
<!-- Small popover -->
<ion-popover trigger="small-popover-trigger" trigger-action="click">
<ion-content class="ion-padding">
<ion-list>
<ion-item><ion-label>Option 1</ion-label></ion-item>
<ion-item><ion-label>Option 2</ion-label></ion-item>
<ion-item><ion-label>Option 3</ion-label></ion-item>
</ion-list>
</ion-content>
</ion-popover>
<!-- Large popover with many items -->
<ion-popover trigger="large-popover-trigger" trigger-action="click">
<ion-content>
<ion-list id="large-list"></ion-list>
</ion-content>
</ion-popover>
<!-- Popover triggered from near bottom -->
<ion-popover trigger="bottom-trigger" trigger-action="click">
<ion-content>
<ion-list id="bottom-list"></ion-list>
</ion-content>
</ion-popover>
<!-- Popover triggered from near bottom right -->
<ion-popover trigger="near-bottom-trigger" trigger-action="click">
<ion-content>
<ion-list id="near-bottom-list"></ion-list>
</ion-content>
</ion-popover>
</ion-content>
</div>
</ion-app>
<script>
// Generate list items for popovers
function generateItems(listId, count) {
const list = document.getElementById(listId);
if (!list) return;
for (let i = 1; i <= count; i++) {
const item = document.createElement('ion-item');
const label = document.createElement('ion-label');
label.textContent = `Item ${i}`;
item.appendChild(label);
list.appendChild(item);
}
}
generateItems('large-list', 15);
generateItems('bottom-list', 10);
generateItems('near-bottom-list', 8);
</script>
</body>
</html>

View File

@@ -0,0 +1,133 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
/**
* Safe-area tests verify that popovers are correctly positioned
* to avoid overlapping with safe-area zones (status bars, navigation bars, etc.)
*
* This is especially important for Android API 36+ where edge-to-edge mode
* is enforced and apps can no longer opt out.
*
* The test HTML includes safe-area values (44px top/left/right, 34px bottom)
* and red visual indicators to verify popover positioning.
*/
// Tests that apply to both iOS and MD modes
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('popover: safe-area positioning'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/popover/test/safe-area', config);
});
test('popover pinned to bottom should account for safe-area-bottom in position', async ({ page }, testInfo) => {
testInfo.annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30900',
});
/**
* Use a small viewport to force the popover to be fully constrained.
* The large popover has 15 items (~700px), which will exceed the available
* space in this viewport, causing it to be constrained with both top and
* bottom edges near the safe areas.
*
* A 300px viewport ensures there's not enough space above OR below the
* trigger for the full popover content, triggering the fully constrained path.
*/
await page.setViewportSize({ width: 375, height: 300 });
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
// Click the large popover trigger which has enough content to extend toward the bottom
await page.click('#large-popover-trigger');
await ionPopoverDidPresent.next();
// Target the specific popover that was presented
const popover = page.locator('ion-popover[trigger="large-popover-trigger"]');
const popoverContent = popover.locator('.popover-content');
// Get the computed bottom style - should include safe-area calc
const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
// The bottom should include the safe-area-bottom CSS variable
// This ensures the popover is positioned above the unsafe area
expect(bottomStyle).toContain('var(--ion-safe-area-bottom');
});
});
});
// iOS-specific tests
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('popover: safe-area positioning - ios specific'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/popover/test/safe-area', config);
});
test('floating popover should not have safe-area adjustments', async ({ page }) => {
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#small-popover-trigger');
await ionPopoverDidPresent.next();
// Target the specific popover
const popover = page.locator('ion-popover[trigger="small-popover-trigger"]');
const popoverContent = popover.locator('.popover-content');
// Get the computed top and bottom styles
const topStyle = await popoverContent.evaluate((el) => el.style.top);
const bottomStyle = await popoverContent.evaluate((el) => el.style.bottom);
// A floating popover in the middle shouldn't have safe-area adjustments
// The top should be a simple calc without safe-area
expect(topStyle).not.toContain('var(--ion-safe-area-top');
// The bottom should not be set for a floating popover
expect(bottomStyle).toBe('');
});
});
});
// Landscape viewport simulates devices with side notches or landscape orientation
const LandscapeViewport = { width: 844, height: 390 };
configs({ modes: ['ios', 'md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('popover: safe-area screenshots'), () => {
test('popover near bottom should avoid bottom safe area', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/popover/test/safe-area', config);
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#bottom-trigger');
await ionPopoverDidPresent.next();
// Red overlays show safe areas - popover should be positioned to avoid them
await expect(page).toHaveScreenshot(screenshot('popover-safe-area-bottom-landscape'));
});
test('popover near bottom right should avoid right safe area', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/popover/test/safe-area', config);
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#near-bottom-trigger');
await ionPopoverDidPresent.next();
// Popover triggered from near-right edge should account for right safe area
await expect(page).toHaveScreenshot(screenshot('popover-safe-area-right-landscape'));
});
test('large popover should avoid all safe areas', async ({ page }) => {
await page.setViewportSize(LandscapeViewport);
await page.goto('/src/components/popover/test/safe-area', config);
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
await page.click('#large-popover-trigger');
await ionPopoverDidPresent.next();
// Large popover may extend toward edges - should respect safe areas
await expect(page).toHaveScreenshot(screenshot('popover-safe-area-large-landscape'));
});
});
});

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -30,11 +30,20 @@ export interface PopoverStyles {
bottom?: number;
originX: string;
originY: string;
checkSafeAreaTop: boolean;
checkSafeAreaBottom: boolean;
checkSafeAreaLeft: boolean;
checkSafeAreaRight: boolean;
arrowTop: number;
arrowLeft: number;
addPopoverBottomClass: boolean;
/**
* When true, the popover content was too tall to fit above or below
* the trigger, so it was constrained to the full viewport height.
* In this case, the arrow should be hidden as it cannot accurately
* point to the trigger.
*/
isFullyConstrained: boolean;
}
/**
@@ -829,8 +838,11 @@ export const calculateWindowAdjustment = (
let bottom;
let originX = contentOriginX;
let originY = contentOriginY;
let checkSafeAreaTop = false;
let checkSafeAreaBottom = false;
let checkSafeAreaLeft = false;
let checkSafeAreaRight = false;
let isFullyConstrained = false;
const triggerTop = triggerCoordinates
? triggerCoordinates.top + triggerCoordinates.height
: bodyHeight / 2 - contentHeight / 2;
@@ -841,20 +853,29 @@ export const calculateWindowAdjustment = (
* Adjust popover so it does not
* go off the left of the screen.
*/
if (left < bodyPadding + safeAreaMargin) {
if (left < bodyPadding) {
left = bodyPadding;
checkSafeAreaLeft = true;
originX = 'left';
/**
* Adjust popover so it does not
* go off the right of the screen.
*/
} else if (contentWidth + bodyPadding + left + safeAreaMargin > bodyWidth) {
checkSafeAreaRight = true;
} else if (contentWidth + bodyPadding + left > bodyWidth) {
left = bodyWidth - contentWidth - bodyPadding;
originX = 'right';
}
/**
* After position adjustment, check if popover is near edges
* and needs safe-area CSS variable adjustments.
*/
if (left <= safeAreaMargin) {
checkSafeAreaLeft = true;
}
if (left + contentWidth >= bodyWidth - safeAreaMargin) {
checkSafeAreaRight = true;
}
/**
* Adjust popover so it does not
* go off the top of the screen.
@@ -863,7 +884,19 @@ export const calculateWindowAdjustment = (
* margins.
*/
if (triggerTop + triggerHeight + contentHeight > bodyHeight && (side === 'top' || side === 'bottom')) {
if (triggerTop - contentHeight > 0) {
/**
* Calculate available space above and below, accounting for safe areas.
* This ensures we flip to whichever side has more usable space.
*/
const spaceAbove = (triggerCoordinates?.top ?? triggerTop) - bodyPadding - safeAreaMargin;
const spaceBelow = bodyHeight - triggerTop - triggerHeight - bodyPadding - safeAreaMargin;
/**
* Flip above if:
* 1. Content fits entirely above the trigger, OR
* 2. There's more usable space above than below (accounting for safe areas)
*/
if (triggerTop - contentHeight > 0 || spaceAbove > spaceBelow) {
/**
* While we strive to align the popover with the trigger
* on smaller screens this is not always possible. As a result,
@@ -874,31 +907,90 @@ export const calculateWindowAdjustment = (
* We chose 12 here so that the popover position looks a bit nicer as
* it is not right up against the edge of the screen.
*/
top = Math.max(12, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));
top = Math.max(bodyPadding, triggerTop - contentHeight - triggerHeight - (arrowHeight - 1));
arrowTop = top + contentHeight;
originY = 'bottom';
addPopoverBottomClass = true;
/**
* If not enough room for popover to appear
* above trigger, then cut it off.
* If the popover is positioned near the top edge, account for safe area.
* This ensures the popover doesn't overlap with status bars or notches.
*/
if (top <= bodyPadding + safeAreaMargin) {
checkSafeAreaTop = true;
top = bodyPadding;
}
/**
* After flipping above, check if popover will likely overflow the viewport.
* This can happen when the popover is taller than the available space.
*
* When checkSafeAreaTop is true, the CSS will add safe-area-top to the
* top position, pushing the popover down. Since we don't know the exact
* CSS safe-area value at runtime, we use a conservative threshold that
* accounts for typical safe-area sizes (usually 40-50px). By checking
* against (safeAreaMargin * 2), we ensure that:
* 1. Any popover close to the viewport boundary gets constrained
* 2. The safe-area CSS variables have room to be applied without overflow
*/
if (checkSafeAreaTop && top + contentHeight > bodyHeight - safeAreaMargin * 2 - bodyPadding) {
bottom = bodyPadding;
checkSafeAreaBottom = true;
isFullyConstrained = true;
}
/**
* If not enough room for popover to appear above trigger
* (i.e., content is taller than space above), then constrain
* the popover to fill the entire viewport from top to bottom.
*/
} else {
top = bodyPadding;
bottom = bodyPadding;
checkSafeAreaTop = true;
checkSafeAreaBottom = true;
isFullyConstrained = true;
}
}
/**
* Check if popover is near edges and needs safe-area adjustments.
* When the popover extends into the safe-area zone, set a bottom constraint
* to push it up and out of the unsafe area. This is essential for
* edge-to-edge displays on Android API 36+ and iOS devices with home indicators.
*/
const popoverBottom = bottom !== undefined ? bodyHeight - bottom : top + contentHeight;
if (popoverBottom > bodyHeight - safeAreaMargin && bottom === undefined) {
checkSafeAreaBottom = true;
/**
* Set a bottom constraint to push the popover up out of the safe-area zone.
* The animation will add the safe-area CSS variable to this value.
*
* We also set isFullyConstrained so that height: unset is applied,
* allowing the bottom constraint to actually take effect (otherwise
* the explicit height would override the bottom constraint).
*/
bottom = bodyPadding;
isFullyConstrained = true;
}
if (top < safeAreaMargin) {
checkSafeAreaTop = true;
}
return {
top,
left,
bottom,
originX,
originY,
checkSafeAreaTop,
checkSafeAreaBottom,
checkSafeAreaLeft,
checkSafeAreaRight,
arrowTop,
arrowLeft,
addPopoverBottomClass,
isFullyConstrained,
};
};

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 46 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB