Skip to content
Merged
65 changes: 65 additions & 0 deletions demo/e2e/foldable-layout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect, test } from '@playwright/test';

for (const direction of ['ltr', 'rtl'] as const) {
test(`menus respect foldable safe-area insets in ${direction}`, async ({ page }) => {
await page.goto('/main/index', { waitUntil: 'networkidle' });
const menu = page.locator('ion-menu');

const result = await menu.evaluate(async (element: HTMLIonMenuElement, direction) => {
const app = document.querySelector('ion-app')!;
app.dir = direction;
app.classList.add('ios-theme-enable-foldable');
element.side = direction === 'ltr' ? 'end' : 'start';
await new Promise(requestAnimationFrame);
const defaultBounds = element.getBoundingClientRect();
const defaultRightOffset = innerWidth - defaultBounds.right;
app.style.setProperty('--ios-theme-foldable-safe-area-left', '76px');
app.style.setProperty('--ios-theme-foldable-safe-area-right', '84px');
app.style.setProperty('--ion-safe-area-left', '76px');
app.style.setProperty('--ion-safe-area-right', '84px');
const offsets = [];
for (const side of ['start', 'end'] as const) {
element.side = side;
await new Promise(requestAnimationFrame);
const bounds = element.getBoundingClientRect();
const physicalSide = side === 'start' ? (direction === 'ltr' ? 'left' : 'right') : direction === 'ltr' ? 'right' : 'left';
offsets.push({ side, physicalSide, offset: physicalSide === 'left' ? bounds.left : innerWidth - bounds.right });
}
const contentStyle = getComputedStyle(element.querySelector('ion-content')!);

const modal = document.createElement('ion-modal');
modal.mode = 'ios';
modal.style.setProperty('--ion-safe-area-right', '12px');
const modalContent = document.createElement('ion-content');
modalContent.mode = 'ios';
modal.append(modalContent);
app.append(modal);
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
if (!modalContent.classList.contains('ios')) throw new Error('Expected an iOS ion-content fixture');
return {
defaultRightOffset,
offsets,
safeAreaLeft: contentStyle.getPropertyValue('--ion-safe-area-left').trim(),
safeAreaRight: contentStyle.getPropertyValue('--ion-safe-area-right').trim(),
modalSafeAreaRight: getComputedStyle(modalContent).getPropertyValue('--ion-safe-area-right').trim(),
};
}, direction);

expect(result.defaultRightOffset).toBe(80);
expect(result.offsets).toEqual(
direction === 'ltr'
? [
{ side: 'start', physicalSide: 'left', offset: 76 },
{ side: 'end', physicalSide: 'right', offset: 84 },
]
: [
{ side: 'start', physicalSide: 'right', offset: 84 },
{ side: 'end', physicalSide: 'left', offset: 76 },
],
);
expect(result.safeAreaLeft).toBe('0px');
expect(result.safeAreaRight).toBe('0px');
expect(result.modalSafeAreaRight).toBe('12px');
});
}
46 changes: 44 additions & 2 deletions demo/e2e/screenshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ const routes = [
{ path: '/main/index/reorder', name: 'reorder' },
{ path: '/main/index/tabs', name: 'tabs' },
{ path: '/main/index/toolbar', name: 'toolbar' },
].sort(() => Math.random() - 0.5);
];

const prepareScreenShot = async (page: Page, routeName: string) => {
await page.waitForTimeout(1000);
await page.waitForSelector('ion-content[role="main"]', { timeout: 10000 });
await page.evaluate(() => document.fonts.ready);
if (!routeName.includes(':')) {
const scrollHeight = await page.locator('ion-content[role="main"]').evaluate(async (el: any) => {
const scrollEl = await el.getScrollElement();
Expand All @@ -60,6 +60,39 @@ const prepareScreenShot = async (page: Page, routeName: string) => {
}
};

const prepareFoldableLayout = async (page: Page, direction: 'ltr' | 'rtl') => {
await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true));
await page.setViewportSize({ width: 700, height: 900 });
await page.goto('/main/index', { waitUntil: 'networkidle' });
await page.waitForSelector('ion-content[role="main"]');
await page.evaluate(() => document.fonts.ready);
await page.evaluate((direction) => {
const app = document.querySelector('ion-app')!;
app.dir = direction;
app.style.setProperty('--ios-theme-foldable-safe-area-left', '76px');
app.style.setProperty('--ios-theme-foldable-safe-area-right', '84px');
app.style.setProperty('--ion-safe-area-left', '76px');
app.style.setProperty('--ion-safe-area-right', '84px');
app.classList.add('ios-theme-enable-foldable');

const content = document.querySelector<HTMLIonContentElement>('ion-content[role="main"]')!;
const logicalLeft = direction === 'ltr' ? 'start' : 'end';
for (const physicalSide of ['left', 'right'] as const) {
const logicalSide = physicalSide === 'left' ? logicalLeft : logicalLeft === 'start' ? 'end' : 'start';
const fab = document.createElement('ion-fab');
fab.mode = 'ios';
fab.dir = direction;
fab.horizontal = logicalSide;
fab.vertical = 'center';
fab.slot = 'fixed';
fab.style.setProperty('--ios-theme-menu-width', '0px');
fab.style.setProperty('--ios26-menu-width', '0px');
fab.innerHTML = `<ion-fab-button mode="ios" aria-label="${physicalSide} action">${physicalSide === 'left' ? 'L' : 'R'}</ion-fab-button>`;
content.append(fab);
}
}, direction);
};

test.describe('Screenshot Tests - All Routes', () => {
for (const route of routes) {
test(`should match screenshot for ${route.name}`, async ({ page }) => {
Expand Down Expand Up @@ -95,3 +128,12 @@ test.describe('Screenshot Tests - Dark Mode', () => {
});
}
});

test.describe('Screenshot Tests - Foldable Layout', () => {
for (const direction of ['ltr', 'rtl'] as const) {
test(`should keep the app foreground clear of foldable system UI in ${direction.toUpperCase()}`, async ({ page }) => {
await prepareFoldableLayout(page, direction);
await expect(page).toHaveScreenshot(`foldable-layout-${direction}.png`, { animations: 'disabled' });
});
}
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion demo/src/app/docs/docs-content.generated.ts

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions docs/special-markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ Add one of `tab-bar-position-start`, `tab-bar-position-center`, or `tab-bar-posi

These classes do not reposition a separate `ion-fab`; leave room for it when choosing the bar's position.

## Foldable layouts

To simulate the iPhone Duo layout on the web, add `.ios-theme-enable-foldable` to the active `ion-app`. Use `body` only when the application has no `ion-app` root:

```html
<ion-app class="ios-theme-enable-foldable">...</ion-app>
```

The class reserves `80px` on the physical right by default, matching the system navigation region measured in the iPhone Duo Simulator. The physical left defaults to `0px`. Override `--ios-theme-foldable-safe-area-left` or `--ios-theme-foldable-safe-area-right` when simulating a different foldable layout.

This keeps routers and component backgrounds full-viewport. `ion-content` moves its scroll foreground, `ion-toolbar` moves its container foreground, and `ion-fab` adjusts only when it is placed beside the system UI. The corresponding Ionic safe-area variable is reset inside those foreground components so descendants do not add the inset again.

`ion-menu`, `ion-modal`, and `ion-popover` are handled as separate surfaces: their internal foreground components do not receive the main-page conversion and retain Ionic's standard safe-area handling. A menu presented beside the system UI is positioned after the corresponding inset; a menu from the opposite side is unchanged. Left and right remain physical coordinates in RTL, while Ionic's `side="start"` and `side="end"` values remain logical.

The foldable values are web-layout simulation inputs. They are independent from Ionic's normal iPhone safe-area variables and do not change ordinary iPhone layouts unless the opt-in class is present.

## Two-line inset list items

Place an unslotted `ion-label` immediately alongside an unslotted `ion-note` to render a two-line item. When using the iOS-style inset-list background, wrap the items in `ion-item-group`; keep `ion-list-header` outside the group.
Expand Down
18 changes: 18 additions & 0 deletions src/styles/components/ion-content.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,21 @@
ion-content.ios:not(.ios-theme-disabled, .ios26-disabled).content-fullscreen:not(:has(.ion-content-scroll-host)) {
--padding-bottom: calc(60px + var(--ios-theme-floating-safe-area-bottom, var(--ios26-floating-safe-area-bottom)));
}

// Foldable system UI changes foreground geometry, not the page background.
// Reset Ionic's matching inset so descendants do not apply it a second time.
:is(ion-app, body).ios-theme-enable-foldable
ion-content.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) {
--ion-safe-area-left: 0px;
--ion-safe-area-right: 0px;

&::part(scroll) {
padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-foldable-safe-area-left-resolved));
padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-foldable-safe-area-right-resolved));
}

&:dir(rtl)::part(scroll) {
padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-foldable-safe-area-right-resolved));
padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-foldable-safe-area-left-resolved));
}
}
36 changes: 36 additions & 0 deletions src/styles/components/ion-fab.scss
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,42 @@ ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled) {
}
}

// FAB alignment is independent from tab placement. Map the reserved physical
// side to the matching logical FAB edge for the current writing direction.
:is(ion-app, body).ios-theme-enable-foldable
ion-fab.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) {
--ion-safe-area-left: 0px;
--ion-safe-area-right: 0px;
--ios-theme-foldable-fab-edge-gap: 16px;
--ios-theme-foldable-fab-start-offset: var(--ios-theme-menu-width, var(--ios26-menu-width, 0px));

&.fab-horizontal-start {
inset-inline-start: calc(
var(--ios-theme-foldable-fab-edge-gap) + var(--ios-theme-foldable-fab-start-offset) +
var(--ios-theme-foldable-safe-area-left-resolved)
);
}

&.fab-horizontal-end {
inset-inline-end: calc(var(--ios-theme-foldable-fab-edge-gap) + var(--ios-theme-foldable-safe-area-right-resolved));
}

&:dir(rtl).fab-horizontal-start {
inset-inline-start: calc(
var(--ios-theme-foldable-fab-edge-gap) + var(--ios-theme-foldable-fab-start-offset) +
var(--ios-theme-foldable-safe-area-right-resolved)
);
}

&:dir(rtl).fab-horizontal-end {
inset-inline-end: calc(var(--ios-theme-foldable-fab-edge-gap) + var(--ios-theme-foldable-safe-area-left-resolved));
}

&:has(.fab-button-small) {
--ios-theme-foldable-fab-edge-gap: 10px;
}
}

// A content fixed slot already starts below its page header, including safe area.
// Keep the floating gap without adding the screen's top inset a second time.
.ion-page:has(> ion-header:not([hidden], .ion-hide))
Expand Down
4 changes: 0 additions & 4 deletions src/styles/components/ion-list.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,6 @@ ion-list.list-inset.ios:not(.ios-theme-disabled, .ios26-disabled) {
padding-right: calc(var(--ion-safe-area-right, 0px) + 18px);
}

&:dir(rtl)::part(native) {
padding-left: calc(var(--ion-safe-area-left, 0px) + 18px);
}

--min-height: 52px;
font-size: 1rem;
line-height: 1.29rem;
Expand Down
19 changes: 19 additions & 0 deletions src/styles/components/ion-menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,22 @@ ion-menu.ios:not(.ios-theme-disabled, .ios26-disabled) {
}
}
}

// A menu beside foldable system UI must end before the reserved region. Its
// own ion-content remains unchanged. The host consumes the matching Ionic
// safe-area variable because its complete surface has already moved inward.
:is(ion-app, body).ios-theme-enable-foldable {
ion-menu.ios.menu-side-end:not(:dir(rtl)):not(.ios-theme-disabled, .ios26-disabled),
ion-menu.ios.menu-side-start:dir(rtl):not(.ios-theme-disabled, .ios26-disabled) {
--ion-safe-area-right: 0px;
right: var(--ios-theme-foldable-safe-area-right-resolved);
left: auto;
}

ion-menu.ios.menu-side-start:not(:dir(rtl)):not(.ios-theme-disabled, .ios26-disabled),
ion-menu.ios.menu-side-end:dir(rtl):not(.ios-theme-disabled, .ios26-disabled) {
--ion-safe-area-left: 0px;
left: var(--ios-theme-foldable-safe-area-left-resolved);
right: auto;
}
}
18 changes: 18 additions & 0 deletions src/styles/components/ion-toolbar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,21 @@ ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled) {
margin-inline: 0;
}
}

// Keep the toolbar background full-width while moving its foreground controls
// away from foldable system UI.
:is(ion-app, body).ios-theme-enable-foldable
ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) {
--ion-safe-area-left: 0px;
--ion-safe-area-right: 0px;

&::part(container) {
padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-foldable-safe-area-left-resolved));
padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-foldable-safe-area-right-resolved));
}

&:dir(rtl)::part(container) {
padding-inline-start: calc(var(--padding-start, 0px) + var(--ios-theme-foldable-safe-area-right-resolved));
padding-inline-end: calc(var(--padding-end, 0px) + var(--ios-theme-foldable-safe-area-left-resolved));
}
}
7 changes: 7 additions & 0 deletions src/styles/default-variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,10 @@
*/
--ios26-content-box-shadow-rgb: var(--ion-color-base-rgb, var(--ion-background-color-rgb, 255, 255, 255));
}

// iPhone Duo web simulation: the iOS 27.1 Simulator is 951pt wide while
// its application window is 871pt wide, leaving an 80pt navigation region.
:is(ion-app, body).ios-theme-enable-foldable {
--ios-theme-foldable-safe-area-left-resolved: var(--ios-theme-foldable-safe-area-left, 0px);
--ios-theme-foldable-safe-area-right-resolved: var(--ios-theme-foldable-safe-area-right, 80px);
}