diff --git a/demo/e2e/foldable-layout.spec.ts b/demo/e2e/foldable-layout.spec.ts new file mode 100644 index 00000000..3dcba1ff --- /dev/null +++ b/demo/e2e/foldable-layout.spec.ts @@ -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'); + }); +} diff --git a/demo/e2e/screenshot.spec.ts b/demo/e2e/screenshot.spec.ts index 4183a3ba..43b09fea 100644 --- a/demo/e2e/screenshot.spec.ts +++ b/demo/e2e/screenshot.spec.ts @@ -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(); @@ -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('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 = `${physicalSide === 'left' ? 'L' : 'R'}`; + content.append(fab); + } + }, direction); +}; + test.describe('Screenshot Tests - All Routes', () => { for (const route of routes) { test(`should match screenshot for ${route.name}`, async ({ page }) => { @@ -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' }); + }); + } +}); diff --git a/demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-ltr.png new file mode 100644 index 00000000..c450e85c Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-rtl.png new file mode 100644 index 00000000..e2c6cbaa Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-rtl.png differ diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index cd46b6ec..a1b02a07 100644 --- a/demo/src/app/docs/docs-content.generated.ts +++ b/demo/src/app/docs/docs-content.generated.ts @@ -1,3 +1,3 @@ // Generated from docs/special-markup.md. Do not edit directly. export const docsContentHtml = - '

Special markup and classes

\n

Most Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.

\n

Primary submit buttons

\n

Solid submit buttons use the Ionic color's contrast value for their foreground. Their directional edge treatment follows the iOS 27 prominent-button appearance and does not require an additional brightness color.

\n
<ion-button type="submit" color="primary">Submit</ion-button>\n<ion-button class="button-submit" fill="solid" color="primary">Continue</ion-button>
\n\n
\n
Preview
\nSubmit\nContinue\n
\n\n

Use .button-submit when the button needs the same treatment but cannot use type="submit".

\n

Preferred overlay actions

\n

For iOS alerts and action sheets, set role: 'preferred' on a button to give it a filled --ion-color-primary background and --ion-color-primary-contrast text and icons. While pressed, the background uses --ion-color-primary-shade. This is a theme convention using Ionic's custom button roles; it does not automatically select or invoke the action. Dismissal reports the role as preferred.

\n
buttons: [\n  { text: \'Cancel\', role: \'cancel\' },\n  { text: \'Continue\', role: \'preferred\' },\n];
\n\n

Buttons with no role or default keep the normal text color. cancel retains Ionic's cancellation behavior, selected remains a selection state, and destructive uses --ios-theme-destructive-color. An existing confirm role is not treated as preferred. Use preferred for the recommended action, not simply any action that confirms a choice.

\n

Floating iPad sheets

\n

Set expandToScroll: false on a sheet modal to use floating lower corners and a 20px bottom gap on iPad. Ionic then sizes the visible page at each breakpoint, so the theme can style it with CSS alone. Content scrolls within the current breakpoint; dragging the handle still resizes the sheet. With the default expandToScroll: true, the sheet keeps Ionic's bottom-attached layout and scroll-to-expand behavior.

\n

Tab bar position

\n

Add one of tab-bar-position-start, tab-bar-position-center, or tab-bar-position-end to an iOS ion-tab-bar to position the whole bar within its safe area. These classes work with both slot="top" and slot="bottom" and preserve the bar's width and press animation. Start and end follow the text direction (reversed in RTL). Without a class, the existing placement is unchanged.

\n
<ion-tab-bar slot="bottom" class="tab-bar-position-center">\n  <ion-tab-button tab="home">Home</ion-tab-button>\n  <ion-tab-button tab="settings">Settings</ion-tab-button>\n</ion-tab-bar>
\n\n

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

\n

Two-line inset list items

\n

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.

\n
<ion-list inset="true">\n  <ion-list-header>\n    <ion-label>Connections</ion-label>\n  </ion-list-header>\n  <ion-item-group>\n    <ion-item>\n      <ion-label>Network &amp; internet</ion-label>\n      <ion-note>Mobile, Wi-Fi, hotspot</ion-note>\n    </ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n Connections\n \n \n \n Network & internet\n Mobile, Wi-Fi, hotspot\n \n \n\n
\n\n

Use slot="end" on ion-note when you want the standard trailing-note layout instead.

\n

Inset-list section headers

\n

Add .item-group-header to an ion-item-group to create the centered icon, title, and description used at the top of the component demo pages.

\n

This is an introductory group. Place regular list items in a separate ion-item-group that follows it.

\n
<ion-list inset="true">\n  <ion-item-group class="item-group-header">\n    <ion-item>\n      <ion-label>\n        <ion-icon name="list" style="background: var(--ion-color-primary)"></ion-icon>\n        <h2>Lists</h2>\n        <ion-text>Inset-list examples</ion-text>\n      </ion-label>\n    </ion-item>\n  </ion-item-group>\n  <ion-item-group>\n    <ion-item><ion-label>First item</ion-label></ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n \n \n \n

Lists

\n Inset-list examples\n
\n
\n
\n \n First item\n \n
\n
\n\n

Full-width segments

\n

Add .segment-style-glass to give a segment the same glass surface and selected indicator treatment as the tab bar. The class preserves the segment's existing dimensions and text colors, supports scrollable segments, and respects Ionic's public --background property.

\n
<ion-segment class="segment-style-glass" value="available">\n  <ion-segment-button value="available">Available</ion-segment-button>\n  <ion-segment-button value="away">Away</ion-segment-button>\n</ion-segment>
\n\n

For colored segments, use Ionic's color property (for example, color="primary" or color="secondary"). Ionic uses the palette's base color for the softly tinted track while keeping the selected surface and labels neutral. A surrounding colored toolbar only supplies colors when the segment has no color of its own. The optional moving glass inherits the selected surface color, and custom Ionic palettes work without additional registration.

\n

Add .segment-expand when segment buttons should divide the available width evenly. The class also changes the Liquid Glass effect sizing when registerSegmentEffect is used.

\n

Segments use a 32px minimum height in content and a 48px minimum height inside ion-toolbar. .segment-expand keeps the compact 32px layout in a toolbar. Compact segments retain Ionic's flat background and indicator colors; only the regular toolbar variant has a glass container and scales its outer container while pressed. Content and expanded segments keep their outer bounds. The optional moving glass lens is independent of the container's background.

\n
<ion-segment class="segment-expand" value="new">\n  <ion-segment-button value="new"><ion-label>New</ion-label></ion-segment-button>\n  <ion-segment-button value="replied"><ion-label>Replied</ion-label></ion-segment-button>\n</ion-segment>
\n\n
\n
Preview
\n\n New\n Replied\n\n
\n\n

Classic search bar in a condense header

\n

The theme gives iOS search bars the iOS 27 appearance by default. Add .searchbar-classic to the search field shown beneath a large title in an ion-header with collapse="condense". It uses the conventional filled iOS appearance and collapses with the large title instead of remaining in the fixed header.

\n

Place it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.

\n

The example uses Ionic's standard collapsible large-title structure. Scroll the preview to collapse the large title and reveal the fixed header.

\n
<div class="ion-page">\n  <ion-header translucent="true">\n    <ion-toolbar color="light">\n      <ion-title>Search</ion-title>\n    </ion-toolbar>\n  </ion-header>\n  <ion-content color="light" fullscreen="true">\n    <ion-header collapse="condense">\n      <ion-toolbar color="light">\n        <ion-title size="large">Search</ion-title>\n      </ion-toolbar>\n      <ion-toolbar color="light">\n        <ion-searchbar class="searchbar-classic" placeholder="Filter results"></ion-searchbar>\n      </ion-toolbar>\n    </ion-header>\n    <ion-list inset="true">\n      <ion-item-group>\n        <ion-item><ion-label>Recent item 1</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 2</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 3</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 4</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 5</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 6</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 7</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 8</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 9</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 10</ion-label></ion-item>\n      </ion-item-group>\n    </ion-list>\n  </ion-content>\n</div>
\n\n
\n
Preview
\n
\n \n \n Search\n \n \n \n \n \n Search\n \n \n \n \n \n \n \n Recent item 1\n Recent item 2\n Recent item 3\n Recent item 4\n Recent item 5\n Recent item 6\n Recent item 7\n Recent item 8\n Recent item 9\n Recent item 10\n \n \n \n
\n
\n\n

The .ion-page wrapper makes this embedded preview behave like a complete routed page. An application using ion-router-outlet normally receives that page container automatically. The inset list and its items only provide enough content to demonstrate scrolling; they are not required by .searchbar-classic.

\n

Search-bar toolbars

\n

Add .toolbar-searchbar when an ion-toolbar combines a search bar with start or end buttons. The class centers the slotted controls and adjusts the spacing around the search field.

\n
<ion-toolbar class="toolbar-searchbar">\n  <ion-buttons slot="start">\n    <ion-button>Cancel</ion-button>\n  </ion-buttons>\n  <ion-searchbar></ion-searchbar>\n</ion-toolbar>
\n\n
\n
Preview
\n\n \n Cancel\n \n \n\n
\n\n

Opting out

\n

Add .ios-theme-disabled to an individual Ionic component when it must retain Ionic's standard iOS styling.

\n

.ios26-disabled is deprecated but remains supported as an alias with the same behavior. Use .ios-theme-disabled for new code.

\n
<ion-button>iOS 27 theme</ion-button> <ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
\n\n
\n
Preview
\niOS 27 theme Standard Ionic button\n
\n\n

For the background model behind inset lists, see Using ion-item-group.

\n'; + '

Special markup and classes

\n

Most Ionic markup works without changes. The combinations below are explicit opt-ins provided by the theme.

\n

Primary submit buttons

\n

Solid submit buttons use the Ionic color's contrast value for their foreground. Their directional edge treatment follows the iOS 27 prominent-button appearance and does not require an additional brightness color.

\n
<ion-button type="submit" color="primary">Submit</ion-button>\n<ion-button class="button-submit" fill="solid" color="primary">Continue</ion-button>
\n\n
\n
Preview
\nSubmit\nContinue\n
\n\n

Use .button-submit when the button needs the same treatment but cannot use type="submit".

\n

Preferred overlay actions

\n

For iOS alerts and action sheets, set role: 'preferred' on a button to give it a filled --ion-color-primary background and --ion-color-primary-contrast text and icons. While pressed, the background uses --ion-color-primary-shade. This is a theme convention using Ionic's custom button roles; it does not automatically select or invoke the action. Dismissal reports the role as preferred.

\n
buttons: [\n  { text: \'Cancel\', role: \'cancel\' },\n  { text: \'Continue\', role: \'preferred\' },\n];
\n\n

Buttons with no role or default keep the normal text color. cancel retains Ionic's cancellation behavior, selected remains a selection state, and destructive uses --ios-theme-destructive-color. An existing confirm role is not treated as preferred. Use preferred for the recommended action, not simply any action that confirms a choice.

\n

Floating iPad sheets

\n

Set expandToScroll: false on a sheet modal to use floating lower corners and a 20px bottom gap on iPad. Ionic then sizes the visible page at each breakpoint, so the theme can style it with CSS alone. Content scrolls within the current breakpoint; dragging the handle still resizes the sheet. With the default expandToScroll: true, the sheet keeps Ionic's bottom-attached layout and scroll-to-expand behavior.

\n

Tab bar position

\n

Add one of tab-bar-position-start, tab-bar-position-center, or tab-bar-position-end to an iOS ion-tab-bar to position the whole bar within its safe area. These classes work with both slot="top" and slot="bottom" and preserve the bar's width and press animation. Start and end follow the text direction (reversed in RTL). Without a class, the existing placement is unchanged.

\n
<ion-tab-bar slot="bottom" class="tab-bar-position-center">\n  <ion-tab-button tab="home">Home</ion-tab-button>\n  <ion-tab-button tab="settings">Settings</ion-tab-button>\n</ion-tab-bar>
\n\n

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

\n

Foldable layouts

\n

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:

\n
<ion-app class="ios-theme-enable-foldable">...</ion-app>
\n\n

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.

\n

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.

\n

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.

\n

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.

\n

Two-line inset list items

\n

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.

\n
<ion-list inset="true">\n  <ion-list-header>\n    <ion-label>Connections</ion-label>\n  </ion-list-header>\n  <ion-item-group>\n    <ion-item>\n      <ion-label>Network &amp; internet</ion-label>\n      <ion-note>Mobile, Wi-Fi, hotspot</ion-note>\n    </ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n Connections\n \n \n \n Network & internet\n Mobile, Wi-Fi, hotspot\n \n \n\n
\n\n

Use slot="end" on ion-note when you want the standard trailing-note layout instead.

\n

Inset-list section headers

\n

Add .item-group-header to an ion-item-group to create the centered icon, title, and description used at the top of the component demo pages.

\n

This is an introductory group. Place regular list items in a separate ion-item-group that follows it.

\n
<ion-list inset="true">\n  <ion-item-group class="item-group-header">\n    <ion-item>\n      <ion-label>\n        <ion-icon name="list" style="background: var(--ion-color-primary)"></ion-icon>\n        <h2>Lists</h2>\n        <ion-text>Inset-list examples</ion-text>\n      </ion-label>\n    </ion-item>\n  </ion-item-group>\n  <ion-item-group>\n    <ion-item><ion-label>First item</ion-label></ion-item>\n  </ion-item-group>\n</ion-list>
\n\n
\n
Preview
\n\n \n \n \n \n

Lists

\n Inset-list examples\n
\n
\n
\n \n First item\n \n
\n
\n\n

Full-width segments

\n

Add .segment-style-glass to give a segment the same glass surface and selected indicator treatment as the tab bar. The class preserves the segment's existing dimensions and text colors, supports scrollable segments, and respects Ionic's public --background property.

\n
<ion-segment class="segment-style-glass" value="available">\n  <ion-segment-button value="available">Available</ion-segment-button>\n  <ion-segment-button value="away">Away</ion-segment-button>\n</ion-segment>
\n\n

For colored segments, use Ionic's color property (for example, color="primary" or color="secondary"). Ionic uses the palette's base color for the softly tinted track while keeping the selected surface and labels neutral. A surrounding colored toolbar only supplies colors when the segment has no color of its own. The optional moving glass inherits the selected surface color, and custom Ionic palettes work without additional registration.

\n

Add .segment-expand when segment buttons should divide the available width evenly. The class also changes the Liquid Glass effect sizing when registerSegmentEffect is used.

\n

Segments use a 32px minimum height in content and a 48px minimum height inside ion-toolbar. .segment-expand keeps the compact 32px layout in a toolbar. Compact segments retain Ionic's flat background and indicator colors; only the regular toolbar variant has a glass container and scales its outer container while pressed. Content and expanded segments keep their outer bounds. The optional moving glass lens is independent of the container's background.

\n
<ion-segment class="segment-expand" value="new">\n  <ion-segment-button value="new"><ion-label>New</ion-label></ion-segment-button>\n  <ion-segment-button value="replied"><ion-label>Replied</ion-label></ion-segment-button>\n</ion-segment>
\n\n
\n
Preview
\n\n New\n Replied\n\n
\n\n

Classic search bar in a condense header

\n

The theme gives iOS search bars the iOS 27 appearance by default. Add .searchbar-classic to the search field shown beneath a large title in an ion-header with collapse="condense". It uses the conventional filled iOS appearance and collapses with the large title instead of remaining in the fixed header.

\n

Place it in a toolbar with a color, such as color="light"; the classic background is derived from that color's contrast value.

\n

The example uses Ionic's standard collapsible large-title structure. Scroll the preview to collapse the large title and reveal the fixed header.

\n
<div class="ion-page">\n  <ion-header translucent="true">\n    <ion-toolbar color="light">\n      <ion-title>Search</ion-title>\n    </ion-toolbar>\n  </ion-header>\n  <ion-content color="light" fullscreen="true">\n    <ion-header collapse="condense">\n      <ion-toolbar color="light">\n        <ion-title size="large">Search</ion-title>\n      </ion-toolbar>\n      <ion-toolbar color="light">\n        <ion-searchbar class="searchbar-classic" placeholder="Filter results"></ion-searchbar>\n      </ion-toolbar>\n    </ion-header>\n    <ion-list inset="true">\n      <ion-item-group>\n        <ion-item><ion-label>Recent item 1</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 2</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 3</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 4</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 5</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 6</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 7</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 8</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 9</ion-label></ion-item>\n        <ion-item><ion-label>Recent item 10</ion-label></ion-item>\n      </ion-item-group>\n    </ion-list>\n  </ion-content>\n</div>
\n\n
\n
Preview
\n
\n \n \n Search\n \n \n \n \n \n Search\n \n \n \n \n \n \n \n Recent item 1\n Recent item 2\n Recent item 3\n Recent item 4\n Recent item 5\n Recent item 6\n Recent item 7\n Recent item 8\n Recent item 9\n Recent item 10\n \n \n \n
\n
\n\n

The .ion-page wrapper makes this embedded preview behave like a complete routed page. An application using ion-router-outlet normally receives that page container automatically. The inset list and its items only provide enough content to demonstrate scrolling; they are not required by .searchbar-classic.

\n

Search-bar toolbars

\n

Add .toolbar-searchbar when an ion-toolbar combines a search bar with start or end buttons. The class centers the slotted controls and adjusts the spacing around the search field.

\n
<ion-toolbar class="toolbar-searchbar">\n  <ion-buttons slot="start">\n    <ion-button>Cancel</ion-button>\n  </ion-buttons>\n  <ion-searchbar></ion-searchbar>\n</ion-toolbar>
\n\n
\n
Preview
\n\n \n Cancel\n \n \n\n
\n\n

Opting out

\n

Add .ios-theme-disabled to an individual Ionic component when it must retain Ionic's standard iOS styling.

\n

.ios26-disabled is deprecated but remains supported as an alias with the same behavior. Use .ios-theme-disabled for new code.

\n
<ion-button>iOS 27 theme</ion-button> <ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
\n\n
\n
Preview
\niOS 27 theme Standard Ionic button\n
\n\n

For the background model behind inset lists, see Using ion-item-group.

\n'; diff --git a/docs/special-markup.md b/docs/special-markup.md index ded0e2f5..0829d087 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -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 +... +``` + +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. diff --git a/src/styles/components/ion-content.scss b/src/styles/components/ion-content.scss index 39a0e7c7..44751566 100644 --- a/src/styles/components/ion-content.scss +++ b/src/styles/components/ion-content.scss @@ -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)); + } +} diff --git a/src/styles/components/ion-fab.scss b/src/styles/components/ion-fab.scss index 631318a9..e3dfc2ac 100644 --- a/src/styles/components/ion-fab.scss +++ b/src/styles/components/ion-fab.scss @@ -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)) diff --git a/src/styles/components/ion-list.scss b/src/styles/components/ion-list.scss index bde9f050..5a593f64 100644 --- a/src/styles/components/ion-list.scss +++ b/src/styles/components/ion-list.scss @@ -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; diff --git a/src/styles/components/ion-menu.scss b/src/styles/components/ion-menu.scss index 3d437eb9..ed23f965 100644 --- a/src/styles/components/ion-menu.scss +++ b/src/styles/components/ion-menu.scss @@ -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; + } +} diff --git a/src/styles/components/ion-toolbar.scss b/src/styles/components/ion-toolbar.scss index 96576690..3640429d 100644 --- a/src/styles/components/ion-toolbar.scss +++ b/src/styles/components/ion-toolbar.scss @@ -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)); + } +} diff --git a/src/styles/default-variables.scss b/src/styles/default-variables.scss index 2e336b1e..78329db4 100644 --- a/src/styles/default-variables.scss +++ b/src/styles/default-variables.scss @@ -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); +}