diff --git a/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/foldable-tab-drag-labels.png b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/foldable-tab-drag-labels.png index 41360459..6c47da04 100644 Binary files a/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/foldable-tab-drag-labels.png and b/demo/e2e/adaptive-tab-bar.spec.ts-snapshots/foldable-tab-drag-labels.png differ diff --git a/demo/e2e/foldable-back-button.spec.ts b/demo/e2e/foldable-back-button.spec.ts new file mode 100644 index 00000000..40020f82 --- /dev/null +++ b/demo/e2e/foldable-back-button.spec.ts @@ -0,0 +1,115 @@ +import { expect, test } from '@playwright/test'; + +test('foldable mode replaces the toolbar back button with an interactive Web projection', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/button'); + const app = page.locator('ion-app'); + await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + + const source = page.locator('app-button ion-header ion-back-button').first(); + const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection'); + await expect(source).toBeHidden(); + await expect(projection).toBeVisible(); + await expect(projection).toHaveCount(1); + + await source.evaluate((element) => { + const original = element.getBoundingClientRect.bind(element); + (window as any).foldableBackButtonReads = 0; + element.getBoundingClientRect = () => { + (window as any).foldableBackButtonReads++; + return original(); + }; + element.toggleAttribute('data-projection-sync'); + }); + await page.waitForTimeout(100); + const settledReads = await page.evaluate(() => (window as any).foldableBackButtonReads); + await page.waitForTimeout(150); + expect(await page.evaluate(() => (window as any).foldableBackButtonReads)).toBe(settledReads); + + await projection.click(); + await expect(page).toHaveURL(/\/main\/index$/); + await expect(projection).toHaveCount(0); +}); + +test('disabling foldable mode restores the toolbar back button', async ({ page }) => { + await page.goto('/main/index/button'); + const app = page.locator('ion-app'); + await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + const source = page.locator('app-button ion-header ion-back-button').first(); + await expect(source).toBeHidden(); + + await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable')); + await expect(source).toBeVisible(); + await expect(page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection')).toHaveCount(0); +}); + +test('Native UI Shell suspension synchronously restores and resumes foldable ownership', async ({ page }) => { + await page.goto('/main/index/button'); + const app = page.locator('ion-app'); + await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + const source = page.locator('app-button ion-header ion-back-button').first(); + const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection'); + await expect(projection).toBeVisible(); + + await page.evaluate(async () => Object.assign(window, { foldableLease: await (window as any).nativeUIShell.suspend() })); + await expect(source).toBeVisible(); + await expect(projection).toHaveCount(0); + + await page.evaluate(async () => (window as any).foldableLease.resume()); + await expect(source).toBeHidden(); + await expect(projection).toBeVisible(); +}); + +test('foldable projection respects shell opt-out and iOS mode', async ({ page }) => { + await page.goto('/main/index/button'); + const app = page.locator('ion-app'); + const source = page.locator('app-button ion-header ion-back-button').first(); + const toolbar = source.locator('xpath=ancestor::ion-toolbar'); + const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection'); + + await source.evaluate((element) => element.classList.add('ios-theme-shell-disabled')); + await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + await expect(projection).toHaveCount(0); + await expect(source).toBeVisible(); + + await source.evaluate((element) => element.classList.remove('ios-theme-shell-disabled', 'ios')); + await toolbar.evaluate((element) => element.classList.remove('ios')); + await expect(projection).toHaveCount(0); + await expect(source).toBeVisible(); +}); + +test('foldable toolbar projects icon actions and preserves text-only actions', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/native-ui-shell'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + + const sourceGroup = page.locator('app-native-ui-shell ion-header ion-buttons[slot="end"]').first(); + const textAction = sourceGroup.getByText('Cancel', { exact: true }); + const iconSource = sourceGroup.locator('ion-button[aria-label="Save"]'); + const iconProjection = page.locator('ion-app > ion-button.ios-theme-foldable-toolbar-projection[aria-label="Save"]'); + + await expect(sourceGroup).toBeVisible(); + await expect(textAction).toBeVisible(); + await expect(iconSource).toBeHidden(); + await expect(iconProjection).toBeVisible(); + await iconProjection.click(); + await expect(page.locator('[data-save-count]')).toHaveText('1'); + + await textAction.evaluate((element) => element.remove()); + await expect(sourceGroup).toBeHidden(); +}); + +test('theme-disabled ion-buttons project their icon actions as independent controls', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/native-ui-shell'); + const app = page.locator('ion-app'); + const sourceGroup = page.locator('[data-glass-group]'); + await sourceGroup.evaluate((element) => element.classList.add('ionic-theme-disabled')); + await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + await expect( + page.locator( + 'ion-app > ion-button.ios-theme-foldable-toolbar-projection:has(ion-icon:is([name="logo-github"], [name="refresh-circle"]))', + ), + ).toHaveCount(2); + await expect(sourceGroup).toBeHidden(); +}); diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index cab5ceb5..18bc72cb 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -345,6 +345,29 @@ test('foldable tabs stay in the web layer instead of using horizontal native pro await expect(bar).toHaveClass(/ios27-enable-gesture/); }); +test('foldable back navigation hands ownership between native and Web projection', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + const app = page.locator('ion-app'); + const source = page.locator('app-native-ui-shell ion-back-button'); + const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection'); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + + await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable')); + await expect(projection).toHaveCount(1); + await expect + .poll(() => + page.evaluate(() => + (window as any).__nativeUIShell.updates.at(-1).controls.some((control: any) => control.kind === 'ion-back-button'), + ), + ) + .toBe(false); + + await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable')); + await expect(projection).toHaveCount(0); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); +}); + test('native click preserves external form submit, disabled, and duplicate protection', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui-shell'); @@ -742,6 +765,27 @@ test('clear ion-buttons share one glass surface and keep independent actions', a await expect(github.locator('button')).toHaveCSS('visibility', 'visible'); }); +test('theme-disabled ion-buttons project eligible buttons independently', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui-shell'); + await page.locator('[data-glass-group]').evaluate((group) => { + group.classList.add('ionic-theme-disabled'); + group.querySelectorAll('ion-button').forEach((button) => (button.fill = 'default')); + }); + await expect + .poll(() => + page.evaluate(() => { + const controls = (window as any).__nativeUIShell.updates.at(-1).controls; + const isDemoAction = (control: any) => control.items.some((item: any) => ['GitHub', 'Refresh'].includes(item.accessibilityLabel)); + return { + buttons: controls.filter((control: any) => control.kind === 'ion-button' && isDemoAction(control)).length, + groups: controls.filter((control: any) => control.kind === 'ion-buttons' && isDemoAction(control)).length, + }; + }), + ) + .toEqual({ buttons: 2, groups: 0 }); +}); + test('all demo pages keep projection consistent through consecutive navigation', async ({ page }) => { test.setTimeout(240000); await page.setViewportSize({ width: 440, height: 956 }); diff --git a/demo/e2e/screenshot.spec.ts b/demo/e2e/screenshot.spec.ts index 0f968715..97528455 100644 --- a/demo/e2e/screenshot.spec.ts +++ b/demo/e2e/screenshot.spec.ts @@ -93,6 +93,31 @@ const prepareFoldableLayout = async (page: Page, direction: 'ltr' | 'rtl', width }, direction); }; +const prepareFoldableBackButton = 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/button', { waitUntil: 'networkidle' }); + await page.evaluate(() => document.fonts.ready); + await page.locator('ion-app').evaluate((app, dir) => { + app.dir = dir; + app.style.setProperty('--ios-theme-foldable-safe-area-right', '84px'); + app.classList.add('ios-theme-enable-foldable'); + }, direction); + await expect(page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection')).toBeVisible(); +}; + +const prepareFoldableToolbar = async (page: Page) => { + await page.addInitScript(() => ((window as any).IONIC_E2E_TESTING = true)); + await page.setViewportSize({ width: 700, height: 900 }); + await page.goto('/main/index/native-ui-shell', { waitUntil: 'networkidle' }); + await page.evaluate(() => document.fonts.ready); + await page.locator('ion-app').evaluate((app) => { + app.style.setProperty('--ios-theme-foldable-safe-area-right', '84px'); + app.classList.add('ios-theme-enable-foldable'); + }); + await expect(page.locator('ion-app > ion-buttons.ios-theme-foldable-toolbar-projection')).not.toHaveCount(0); +}; + test.describe('Screenshot Tests - All Routes', () => { for (const route of routes) { test(`should match screenshot for ${route.name}`, async ({ page }) => { @@ -143,3 +168,17 @@ test.describe('Screenshot Tests - Foldable Layout', () => { }); } }); + +test.describe('Screenshot Tests - Foldable Back Button', () => { + for (const direction of ['ltr', 'rtl'] as const) { + test(`should project the active back button into the physical system rail in ${direction.toUpperCase()}`, async ({ page }) => { + await prepareFoldableBackButton(page, direction); + await expect(page).toHaveScreenshot(`foldable-back-button-${direction}.png`, { animations: 'disabled' }); + }); + } + + test('should project icon actions while keeping text actions in the toolbar', async ({ page }) => { + await prepareFoldableToolbar(page); + await expect(page).toHaveScreenshot('foldable-toolbar-actions.png', { animations: 'disabled' }); + }); +}); diff --git a/demo/e2e/screenshot.spec.ts-snapshots/foldable-back-button-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/foldable-back-button-ltr.png new file mode 100644 index 00000000..8252345f Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/foldable-back-button-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/foldable-back-button-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/foldable-back-button-rtl.png new file mode 100644 index 00000000..b10f62ef Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/foldable-back-button-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-ltr.png index c450e85c..74e72e4b 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-ltr.png 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 index e2c6cbaa..add7d4db 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-rtl.png and b/demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/foldable-split-pane-ltr.png b/demo/e2e/screenshot.spec.ts-snapshots/foldable-split-pane-ltr.png index 8d9f6759..e587a601 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/foldable-split-pane-ltr.png and b/demo/e2e/screenshot.spec.ts-snapshots/foldable-split-pane-ltr.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/foldable-split-pane-rtl.png b/demo/e2e/screenshot.spec.ts-snapshots/foldable-split-pane-rtl.png index 8e5af8e2..36319c5a 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/foldable-split-pane-rtl.png and b/demo/e2e/screenshot.spec.ts-snapshots/foldable-split-pane-rtl.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/foldable-toolbar-actions.png b/demo/e2e/screenshot.spec.ts-snapshots/foldable-toolbar-actions.png new file mode 100644 index 00000000..03cddb11 Binary files /dev/null and b/demo/e2e/screenshot.spec.ts-snapshots/foldable-toolbar-actions.png differ diff --git a/demo/src/app/docs/docs-content.generated.ts b/demo/src/app/docs/docs-content.generated.ts index a6c6ac19..88e3180a 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

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

When the app contains ion-tabs, foldable mode moves its iOS tab bar into the physical right-side reserved region and aligns it above the bottom safe area. The Ionic slot value does not select a different foldable position. The stable rail is icon-only, matching a four-tab SwiftUI TabView on iPhone Duo. While the user presses and drags across the rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The tab bar remains in the web layer and receives pointer input in the simulated system region. Use ion-menu when navigation should become a sidebar; foldable mode does not convert tabs into a menu.

\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

When the app contains ion-tabs, foldable mode moves its iOS tab bar into the physical right-side reserved region and aligns it above the bottom safe area. The Ionic slot value does not select a different foldable position. The stable rail is icon-only, matching a four-tab SwiftUI TabView on iPhone Duo. While the user presses and drags across the rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The tab bar remains in the web layer and receives pointer input in the simulated system region. Use ion-menu when navigation should become a sidebar; foldable mode does not convert tabs into a menu.

\n

When enableNativeUIShell() is initialized at application startup, foldable mode uses the same document-level projection lifecycle for fixed-toolbar navigation and actions. The active ion-back-button and ion-button or ion-menu-button controls that contain an icon remain the sources of truth, are hidden while owned, and are represented by interactive Web clones in the physical right-side system rail. Buttons in the same ion-buttons group share a vertical rail group. An icon-and-text button is shown as its icon while retaining its accessible label; a text-only button stays in the original toolbar. Menus, modals, and popovers retain their own toolbar layout.

\n

These Web projections also work when no ion-tabs exists and when native projection is unavailable. Disabling foldable mode or leaving the page removes the clones and restores their sources. Override --ios-theme-foldable-toolbar-top when the simulated system controls use a different vertical layout.

\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/demo/src/app/native-ui-shell/native-ui-shell.page.html b/demo/src/app/native-ui-shell/native-ui-shell.page.html index 866f778e..bcdbbd6f 100644 --- a/demo/src/app/native-ui-shell/native-ui-shell.page.html +++ b/demo/src/app/native-ui-shell/native-ui-shell.page.html @@ -3,6 +3,7 @@ Native UI Shell + Cancel Save @@ -32,14 +33,10 @@
-

- Save count: {{ saves() }} -

+

Save count: {{ saves() }}

Experimental: On supported iOS versions, fixed glass controls are rendered natively.

-

- Selection: {{ selected() }} / Change count: {{ changes() }} -

+

Selection: {{ selected() }} / Change count: {{ changes() }}

Scroll the background to compare transparency and refraction in the top-right button.

@@ -47,13 +44,13 @@ @for (value of fills; track value) { - + }
@for (band of bands; track band) { -
- {{ band }} — GLASS / Glass / 123456789 -
+
+ {{ band }} — GLASS / Glass / 123456789 +
}
diff --git a/demo/src/foldable-web.spec.ts b/demo/src/foldable-web.spec.ts new file mode 100644 index 00000000..172c9436 --- /dev/null +++ b/demo/src/foldable-web.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'vitest'; +import { createFoldableWebProjection } from '../../src/native/foldable-web'; + +const mountEligibleBackButton = () => { + document.body.innerHTML = ` + +
+ +
+
`; + const button = document.querySelector('ion-back-button') as HTMLElement; + button.style.display = 'block'; + button.style.visibility = 'visible'; + button.getBoundingClientRect = () => ({ width: 44, height: 44 }) as DOMRect; +}; + +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + +test('toolbar opt-out leaves an otherwise eligible foldable back button under application ownership', async () => { + mountEligibleBackButton(); + const enabled = createFoldableWebProjection(document, {}); + await nextFrame(); + expect(enabled.getStatus().projected).toBe(1); + expect(document.querySelector('.ios-theme-foldable-back-button-projection')).not.toBeNull(); + await enabled.destroy(); + + mountEligibleBackButton(); + const handle = createFoldableWebProjection(document, { controls: { tabs: true } }); + await nextFrame(); + + expect(handle.getStatus()).toEqual({ state: 'web', projected: 0, updates: 0 }); + expect(document.querySelector('ion-back-button')?.hasAttribute('data-native-ui-shell')).toBe(false); + expect(document.querySelector('.ios-theme-foldable-back-button-projection')).toBeNull(); + + await handle.destroy(); +}); diff --git a/docs/special-markup.md b/docs/special-markup.md index ef09f719..5ca190a3 100644 --- a/docs/special-markup.md +++ b/docs/special-markup.md @@ -65,6 +65,10 @@ The foldable values are web-layout simulation inputs. They are independent from When the app contains `ion-tabs`, foldable mode moves its iOS tab bar into the physical right-side reserved region and aligns it above the bottom safe area. The Ionic `slot` value does not select a different foldable position. The stable rail is icon-only, matching a four-tab SwiftUI `TabView` on iPhone Duo. While the user presses and drags across the rail, every icon-and-label tab reveals its label so the pending destination stays identifiable. The tab bar remains in the web layer and receives pointer input in the simulated system region. Use `ion-menu` when navigation should become a sidebar; foldable mode does not convert tabs into a menu. +When `enableNativeUIShell()` is initialized at application startup, foldable mode uses the same document-level projection lifecycle for fixed-toolbar navigation and actions. The active `ion-back-button` and `ion-button` or `ion-menu-button` controls that contain an icon remain the sources of truth, are hidden while owned, and are represented by interactive Web clones in the physical right-side system rail. Buttons in the same `ion-buttons` group share a vertical rail group. An icon-and-text button is shown as its icon while retaining its accessible label; a text-only button stays in the original toolbar. Menus, modals, and popovers retain their own toolbar layout. + +These Web projections also work when no `ion-tabs` exists and when native projection is unavailable. Disabling foldable mode or leaving the page removes the clones and restores their sources. Override `--ios-theme-foldable-toolbar-top` when the simulated system controls use a different vertical layout. + ## 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/native/components/index.ts b/src/native/components/index.ts index 89711c5a..2d414108 100644 --- a/src/native/components/index.ts +++ b/src/native/components/index.ts @@ -5,7 +5,7 @@ import * as menuButton from './ion-menu-button'; import * as tabBar from './ion-tab-bar'; import * as segment from './ion-segment'; import * as fab from './ion-fab'; -import { visible } from '../shared/dom'; +import { isDisabledButtonGroupChild, visible } from '../shared/dom'; import type { Candidate, Identify } from '../shared/candidate'; // Static composition only. Each component declares its own tag, discovery and reader. @@ -30,7 +30,11 @@ export const motionSelector = [ export const readCandidate = (element: HTMLElement, id: Identify): Candidate | undefined => { if (!element.classList.contains('ios') || !visible(element) || element.closest('ion-modal, ion-popover')) return; const style = getComputedStyle(element); - if (!style.getPropertyValue('--ios-theme-glass-background-rgb').trim() && !style.getPropertyValue('--ios26-glass-background-rgb').trim()) + if ( + !isDisabledButtonGroupChild(element) && + !style.getPropertyValue('--ios-theme-glass-background-rgb').trim() && + !style.getPropertyValue('--ios26-glass-background-rgb').trim() + ) return; if (element.contains(element.ownerDocument.activeElement)) return; return components.find((component) => component.tag === element.localName)?.read(element, id); diff --git a/src/native/foldable-web.ts b/src/native/foldable-web.ts new file mode 100644 index 00000000..e5336838 --- /dev/null +++ b/src/native/foldable-web.ts @@ -0,0 +1,338 @@ +import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellStatus } from './definitions'; +import { isExcluded, isShellDisabled, marker, unprojected } from './shared/dom'; + +const backProjectionClass = 'ios-theme-foldable-back-button-projection'; +const toolbarProjectionClass = 'ios-theme-foldable-toolbar-projection'; +const readyClass = 'ios-theme-foldable-toolbar-ready'; +const toolbarControlSize = 46; +const toolbarControlGap = 10; + +interface ToolbarProjection { + source: HTMLIonButtonsElement; + projection: HTMLElement; + actions: { source: HTMLElement; projection: HTMLElement; previousAriaHidden: string | null }[]; +} + +interface ToolbarSource { + group: HTMLIonButtonsElement; + actions: HTMLElement[]; +} + +export const createFoldableWebProjection = (doc: Document, options: NativeUIShellOptions): NativeUIShellHandle => { + const win = doc.defaultView!; + if (options.controls !== undefined && options.controls.toolbar !== true) + return { + getStatus: () => ({ state: 'web', projected: 0, updates: 0 }), + suspend: async () => ({ resume: async () => {} }), + destroy: async () => {}, + }; + let root: HTMLElement | undefined; + let backSource: HTMLIonBackButtonElement | undefined; + let backProjection: HTMLIonBackButtonElement | undefined; + let previousBackAriaHidden: string | null = null; + let toolbarProjections: ToolbarProjection[] = []; + let suspended = 0; + let stopped = false; + let frame = 0; + let updates = 0; + let observingFoldable = false; + let sourceObserver: MutationObserver | undefined; + let waiters: (() => void)[] = []; + const listeners = new AbortController(); + const foldableRoot = () => doc.querySelector(':is(ion-app, body).ios-theme-enable-foldable'); + const projectedSources = () => + [backSource, ...toolbarProjections.flatMap(({ actions }) => actions.map(({ source }) => source))].filter( + (source): source is HTMLElement => !!source, + ); + const isRendered = (element: HTMLElement) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return element.isConnected && style.display !== 'none' && style.visibility === 'visible' && rect.width > 0 && rect.height > 0; + }; + const inEligibleToolbar = (element: HTMLElement) => { + const currentRoot = foldableRoot(); + const toolbar = element.closest('ion-toolbar'); + const edge = toolbar?.parentElement; + return ( + !!currentRoot?.contains(element) && + element.matches('.ios') && + !!toolbar?.matches('.ios') && + !!edge?.matches('ion-header, ion-footer') && + !element.closest('ion-content') && + !edge.hasAttribute('collapse') && + !isExcluded(element) && + !isShellDisabled(element) && + !element.closest('ion-menu, ion-modal, ion-popover, .ion-page-hidden, .ion-page-invisible') + ); + }; + const isEligibleBack = (element: HTMLIonBackButtonElement) => + inEligibleToolbar(element) && unprojected(projectedSources(), () => isRendered(element)); + const isToolbarAction = (element: HTMLElement) => { + if (!element.matches('ion-button.ios, ion-menu-button.ios') || isExcluded(element) || isShellDisabled(element)) return false; + if (element.matches('ion-menu-button')) return true; + return !!element.querySelector('ion-icon, svg'); + }; + const toolbarActions = (element: HTMLIonButtonsElement) => + Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement && isToolbarAction(child)); + const pageOrder = (element: Element) => Array.from(doc.querySelectorAll('.ion-page')).indexOf(element.closest('.ion-page')!); + const findBack = () => { + const candidates = Array.from(doc.querySelectorAll(`ion-back-button:not(.${backProjectionClass})`)).filter( + isEligibleBack, + ); + return candidates.sort((a, b) => { + const order = pageOrder(b) - pageOrder(a); + if (order) return order; + return Number(!!b.closest('ion-header')) - Number(!!a.closest('ion-header')); + })[0]; + }; + const findToolbarGroups = () => { + const candidates = Array.from(doc.querySelectorAll(`ion-buttons.ios:not(.${toolbarProjectionClass})`)).flatMap( + (group): ToolbarSource[] => { + const actions = toolbarActions(group).filter((action) => unprojected(projectedSources(), () => isRendered(action))); + if (!actions.length) return []; + if (group.matches('.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled')) + return actions.filter(inEligibleToolbar).map((action) => ({ group, actions: [action] })); + return inEligibleToolbar(group) && unprojected(projectedSources(), () => isRendered(group)) ? [{ group, actions }] : []; + }, + ); + const highestPage = Math.max(...candidates.map(({ group }) => pageOrder(group))); + return candidates.filter(({ group }) => pageOrder(group) === highestPage); + }; + const isCurrentToolbarAction = (source: HTMLElement) => findToolbarGroups().some(({ actions }) => actions.includes(source)); + const restore = () => { + backProjection?.remove(); + backProjection = undefined; + if (backSource) { + backSource.removeAttribute(marker); + if (previousBackAriaHidden === null) backSource.removeAttribute('aria-hidden'); + else backSource.setAttribute('aria-hidden', previousBackAriaHidden); + backSource.dispatchEvent(new CustomEvent('nativeUIShellChange')); + } + backSource = undefined; + previousBackAriaHidden = null; + for (const { projection, actions } of toolbarProjections) { + projection.remove(); + for (const { source, previousAriaHidden } of actions) { + source.removeAttribute(marker); + if (previousAriaHidden === null) source.removeAttribute('aria-hidden'); + else source.setAttribute('aria-hidden', previousAriaHidden); + source.dispatchEvent(new CustomEvent('nativeUIShellChange')); + } + } + toolbarProjections = []; + sourceObserver?.disconnect(); + sourceObserver = undefined; + root?.classList.remove(readyClass); + root = undefined; + }; + const copyAttributes = (target: HTMLElement, original: HTMLElement) => { + for (const name of ['id', 'slot', marker, 'aria-hidden']) target.removeAttribute(name); + for (const attribute of Array.from(target.attributes)) + if (!['class', marker].includes(attribute.name) && !original.hasAttribute(attribute.name)) target.removeAttribute(attribute.name); + for (const attribute of Array.from(original.attributes)) + if (!['id', 'slot', marker, 'aria-hidden'].includes(attribute.name)) target.setAttribute(attribute.name, attribute.value); + }; + const syncBack = (target: HTMLIonBackButtonElement, original: HTMLIonBackButtonElement) => { + copyAttributes(target, original); + target.classList.add(backProjectionClass, 'ion-cloned-element'); + target.disabled = original.disabled; + target.defaultHref = original.defaultHref; + target.icon = original.icon; + target.color = original.color; + target.mode = original.mode; + target.text = ''; + }; + const syncAction = (target: HTMLElement, original: HTMLElement) => { + copyAttributes(target, original); + target.classList.add('ios-theme-foldable-toolbar-action', 'ion-cloned-element'); + const label = original.getAttribute('aria-label') ?? original.textContent?.trim(); + if (label) target.setAttribute('aria-label', label); + if ('disabled' in original) (target as HTMLIonButtonElement).disabled = (original as HTMLIonButtonElement).disabled; + if (original.matches('ion-button')) { + const icon = original.querySelector('ion-icon, svg'); + const clonedIcon = icon?.cloneNode(true) as HTMLElement | undefined; + clonedIcon?.setAttribute('slot', 'icon-only'); + target.replaceChildren(...(clonedIcon ? [clonedIcon] : [])); + } + }; + const sameSources = (back: HTMLIonBackButtonElement | undefined, groups: ToolbarSource[]) => + back === backSource && + groups.length === toolbarProjections.length && + groups.every(({ group, actions }, index) => { + const current = toolbarProjections[index]; + return ( + group === current.source && + actions.length === current.actions.length && + actions.every((action, actionIndex) => action === current.actions[actionIndex].source) + ); + }); + const syncExisting = () => { + if (backSource && backProjection) syncBack(backProjection, backSource); + let topOffset = backSource ? toolbarControlSize + toolbarControlGap : 0; + for (const { projection, source, actions } of toolbarProjections) { + if (actions.length === 1 && !projection.matches('ion-buttons')) syncAction(projection, actions[0].source); + else copyAttributes(projection, source); + projection.classList.add(toolbarProjectionClass, 'ion-cloned-element'); + projection.style.setProperty('--ios-theme-foldable-toolbar-offset', `${topOffset}px`); + if (projection.matches('ion-buttons')) actions.forEach(({ source: action, projection: clone }) => syncAction(clone, action)); + topOffset += actions.length * toolbarControlSize + toolbarControlGap; + } + }; + const project = (nextBack: HTMLIonBackButtonElement | undefined, groups: ToolbarSource[]) => { + root = foldableRoot()!; + let topOffset = 0; + if (nextBack) { + backSource = nextBack; + previousBackAriaHidden = nextBack.getAttribute('aria-hidden'); + backProjection = nextBack.cloneNode(false) as HTMLIonBackButtonElement; + syncBack(backProjection, nextBack); + backProjection.addEventListener( + 'click', + (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + if (backSource && backSource === findBack()) backSource.click(); + }, + { capture: true }, + ); + root.append(backProjection); + nextBack.setAttribute(marker, ''); + nextBack.setAttribute('aria-hidden', 'true'); + nextBack.dispatchEvent(new CustomEvent('nativeUIShellChange')); + topOffset = toolbarControlSize + toolbarControlGap; + } + for (const { group, actions: sources } of groups) { + const projection = (sources.length === 1 ? sources[0] : group).cloneNode(false) as HTMLElement; + if (sources.length === 1) syncAction(projection, sources[0]); + else copyAttributes(projection, group); + projection.classList.add(toolbarProjectionClass, 'ion-cloned-element'); + projection.style.setProperty('--ios-theme-foldable-toolbar-offset', `${topOffset}px`); + const actions = sources.map((source) => { + const clone = sources.length === 1 ? projection : (source.cloneNode(false) as HTMLElement); + if (sources.length > 1) syncAction(clone, source); + clone.addEventListener( + 'click', + (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + if (isCurrentToolbarAction(source)) source.click(); + }, + { capture: true }, + ); + if (sources.length > 1) projection.append(clone); + const previousAriaHidden = source.getAttribute('aria-hidden'); + source.setAttribute(marker, ''); + source.setAttribute('aria-hidden', 'true'); + source.dispatchEvent(new CustomEvent('nativeUIShellChange')); + return { source, projection: clone, previousAriaHidden }; + }); + root.append(projection); + toolbarProjections.push({ source: group, projection, actions }); + topOffset += actions.length * toolbarControlSize + toolbarControlGap; + } + if (nextBack || toolbarProjections.length) root.classList.add(readyClass); + sourceObserver = new MutationObserver(schedule); + if (nextBack?.shadowRoot) sourceObserver.observe(nextBack.shadowRoot, { subtree: true, childList: true, attributes: true }); + for (const { group } of groups) sourceObserver.observe(group, { subtree: true, childList: true, attributes: true }); + }; + const performUpdate = () => { + frame = 0; + const currentRoot = foldableRoot(); + if (observingFoldable !== !!currentRoot) { + observingFoldable = !!currentRoot; + observer.disconnect(); + observer.observe(doc.documentElement, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: observingFoldable ? undefined : ['class'], + }); + } + if (stopped || suspended || !currentRoot) return restore(); + const nextBack = findBack(); + const groups = findToolbarGroups(); + if (!nextBack && !groups.length) return restore(); + if (sameSources(nextBack, groups)) return syncExisting(); + restore(); + project(nextBack, groups); + updates++; + }; + const update = () => { + try { + performUpdate(); + } catch { + restore(); + } finally { + const pending = waiters; + waiters = []; + pending.forEach((resolve) => resolve()); + } + }; + const schedule = (): Promise => { + if (stopped) return Promise.resolve(); + const done = new Promise((resolve) => waiters.push(resolve)); + if (!frame) frame = win.requestAnimationFrame(update); + return done; + }; + const observer = new MutationObserver((records) => { + const insideProjection = (target: Node) => { + const element = target instanceof Element ? target : target.parentNode instanceof Element ? target.parentNode : undefined; + const rootNode = target.getRootNode(); + const shadowHost = rootNode instanceof ShadowRoot ? rootNode.host : undefined; + return !![element, shadowHost].some((candidate) => candidate?.closest(`.${backProjectionClass}, .${toolbarProjectionClass}`)); + }; + const foldableChanged = records.some( + (record) => + (record.type === 'attributes' && (record.target as Element).matches('ion-app, body')) || + (record.type === 'childList' && + Array.from(record.addedNodes).some( + (node) => + node instanceof Element && + (node.matches(':is(ion-app, body).ios-theme-enable-foldable') || + !!node.querySelector(':is(ion-app, body).ios-theme-enable-foldable')), + )), + ); + if ( + (observingFoldable && records.some((record) => record.attributeName !== marker && !insideProjection(record.target))) || + (!observingFoldable && foldableChanged) + ) + schedule(); + }); + observer.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['class'] }); + for (const name of ['ionViewDidEnter', 'ionViewDidLeave', 'ionModalWillPresent', 'ionModalDidDismiss']) + doc.addEventListener(name, schedule, { capture: true, signal: listeners.signal }); + if (options.controls === undefined || options.controls.toolbar === true) schedule(); + + return { + getStatus: (): NativeUIShellStatus => ({ + state: 'web', + projected: Number(!!backSource) + toolbarProjections.reduce((count, group) => count + group.actions.length, 0), + updates, + }), + async suspend() { + suspended++; + restore(); + let resumed = false; + return { + async resume() { + if (resumed) return; + resumed = true; + suspended = Math.max(0, suspended - 1); + if (!suspended) await schedule(); + }, + }; + }, + async destroy() { + if (stopped) return; + stopped = true; + win.cancelAnimationFrame(frame); + frame = 0; + waiters.forEach((resolve) => resolve()); + waiters = []; + observer.disconnect(); + sourceObserver?.disconnect(); + listeners.abort(); + restore(); + }, + }; +}; diff --git a/src/native/index.ts b/src/native/index.ts index 00682612..5fb9cfb7 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -3,6 +3,7 @@ import { setConfig } from '../transition/ios.transition'; import type { NativeUIShellHandle, NativeUIShellOptions, NativeUIShellPlugin, WebViewMetrics } from './definitions'; import { bindMetricsLifecycle } from './lifecycle'; import { createRuntime } from './runtime'; +import { createFoldableWebProjection } from './foldable-web'; export type { NativeUIShellComponent, NativeUIShellControls, @@ -40,23 +41,56 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise }) : Promise.resolve(web('Disabled')); } - if (typeof document === 'undefined' || Capacitor.getPlatform() !== 'ios') return Promise.resolve(web('Requires Capacitor iOS')); + if (typeof document === 'undefined') return Promise.resolve(web('Requires a document')); return (active ??= (async () => { + const foldableWeb = createFoldableWebProjection(document, options); + if (Capacitor.getPlatform() !== 'ios') return resetOnDestroy(withReason(foldableWeb, 'Requires Capacitor iOS')); + let runtime: NativeUIShellHandle | undefined; try { await configureNativeTransition().catch(() => undefined); if (!(await plugin.configure()).supported) { - active = undefined; - return web('Requires iOS 26 or later'); + return resetOnDestroy(withReason(foldableWeb, 'Requires iOS 26 or later')); } - const runtime = await createRuntime(document, plugin, options); - return await bindMetricsLifecycle( + runtime = await createRuntime(document, plugin, options); + runtime = await bindMetricsLifecycle( runtime, () => plugin.addListener('webViewMetricsChange', (metrics) => setConfig({ radius: metrics.radius })), () => (active = undefined), ); + return resetOnDestroy(combine(runtime, foldableWeb)); } catch (error) { - active = undefined; - return web(error instanceof Error ? error.message : String(error)); + await runtime?.destroy(); + return resetOnDestroy(withReason(foldableWeb, error instanceof Error ? error.message : String(error))); } })()); }; + +const combine = (native: NativeUIShellHandle, foldableWeb: NativeUIShellHandle): NativeUIShellHandle => ({ + getStatus: () => { + const nativeStatus = native.getStatus(); + const webStatus = foldableWeb.getStatus(); + return { ...nativeStatus, projected: nativeStatus.projected + webStatus.projected }; + }, + async suspend() { + const [nativeLease, webLease] = await Promise.all([native.suspend(), foldableWeb.suspend()]); + return { resume: async () => void (await Promise.all([nativeLease.resume(), webLease.resume()])) }; + }, + async destroy() { + await Promise.all([native.destroy(), foldableWeb.destroy()]); + }, +}); + +const resetOnDestroy = (handle: NativeUIShellHandle): NativeUIShellHandle => ({ + getStatus: handle.getStatus, + suspend: handle.suspend, + async destroy() { + await handle.destroy(); + active = undefined; + }, +}); + +const withReason = (handle: NativeUIShellHandle, reason: string): NativeUIShellHandle => ({ + getStatus: () => ({ ...handle.getStatus(), reason }), + suspend: () => handle.suspend(), + destroy: () => handle.destroy(), +}); diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 893f1ca3..4955d114 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -125,6 +125,12 @@ export const createRuntime = async ( const candidateSources = (candidate: Candidate) => candidate.sources ?? [candidate.element]; const readEnabledCandidate = (element: HTMLElement): Candidate | undefined => { const candidate = readCandidate(element, id); + if ( + candidate && + ['ion-back-button', 'ion-buttons', 'ion-menu-button'].includes(candidate.control.kind) && + element.closest(':is(ion-app, body).ios-theme-enable-foldable') + ) + return undefined; return candidate && controlEnabled(candidate) ? candidate : undefined; }; const flush = async () => { diff --git a/src/native/shared/dom.ts b/src/native/shared/dom.ts index 30096285..48df88fe 100644 --- a/src/native/shared/dom.ts +++ b/src/native/shared/dom.ts @@ -6,8 +6,17 @@ export const marker = 'data-native-ui-shell'; export const isDark = (style: CSSStyleDeclaration): boolean => style.getPropertyValue('--ios27-color-scheme').trim() === 'dark'; export const excluded = '.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled, .ion-page-hidden, .ion-page-invisible, .ion-cloned-element, [hidden], [inert]'; +const disabledButtonGroup = 'ion-buttons:is(.ionic-theme-disabled, .ios-theme-disabled, .ios26-disabled)'; const shellDisabledSelector = '.ios-theme-shell-disabled'; +export const isDisabledButtonGroupChild = (element: HTMLElement): boolean => + element.matches('ion-button.ios') && element.parentElement?.matches(disabledButtonGroup) === true; + +export const isExcluded = (element: HTMLElement): boolean => { + const owner = element.closest(excluded); + return !!owner && !(element.parentElement === owner && isDisabledButtonGroupChild(element)); +}; + // A shared native surface must not cover an opted-out descendant either. export const isShellDisabled = (element: Element): boolean => !!element.closest(shellDisabledSelector) || !!element.querySelector(shellDisabledSelector); @@ -23,7 +32,7 @@ export const unprojected = (elements: Iterable, read: () => T): }; export const visible = (element: HTMLElement): boolean => { - if (!element.isConnected || element.closest(excluded) || isShellDisabled(element)) return false; + if (!element.isConnected || isExcluded(element) || isShellDisabled(element)) return false; for (let current: HTMLElement | null = element; current; current = current.parentElement) { const style = getComputedStyle(current); if (style.display === 'none' || style.visibility !== 'visible' || (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker))) diff --git a/src/styles/components/ion-button.scss b/src/styles/components/ion-button.scss index 5a0cacfb..bb57c26b 100644 --- a/src/styles/components/ion-button.scss +++ b/src/styles/components/ion-button.scss @@ -436,3 +436,92 @@ ion-button.ios:not(.ios-theme-disabled, .ios26-disabled) { ion-back-button.ios:not(.ios-theme-disabled, .ios26-disabled) { @include theme-button($is-back-button: true); } + +:is(ion-app, body).ios-theme-enable-foldable.ios-theme-foldable-toolbar-ready { + ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-back-button.ios[data-native-ui-shell]:not(.ios-theme-disabled, .ios26-disabled, .ios-theme-foldable-back-button-projection) { + display: none; + } + + ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-buttons.ios + > :is(ion-button, ion-menu-button).ios[data-native-ui-shell]:not( + .ios-theme-disabled, + .ios26-disabled, + .ios-theme-foldable-toolbar-action + ) { + display: none; + } + + ion-toolbar.ios:not(.ios-theme-disabled, .ios26-disabled):not(:where(ion-menu *, ion-modal *, ion-popover *)) + ion-buttons.ios:has(> :is(ion-back-button, ion-button, ion-menu-button)[data-native-ui-shell]):not( + :has(> :is(ion-back-button, ion-button, ion-menu-button):not([data-native-ui-shell])) + ) { + display: none; + } + + > ion-back-button.ios.ios-theme-foldable-back-button-projection { + position: fixed; + z-index: 1001; + top: var(--ios-theme-foldable-toolbar-top, 220px); + right: max(4px, calc((var(--ios-theme-foldable-safe-area-right-resolved) - 46px) / 2)); + display: block; + width: 46px; + height: 46px; + margin: 0; + } + + > .ios.ios-theme-foldable-toolbar-projection { + position: fixed; + z-index: 1001; + top: calc(var(--ios-theme-foldable-toolbar-top, 220px) + var(--ios-theme-foldable-toolbar-offset, 0px)); + right: max(4px, calc((var(--ios-theme-foldable-safe-area-right-resolved) - 46px) / 2)); + width: 46px; + min-width: 46px; + height: 46px; + min-height: 46px; + margin: 0; + + :is(ion-icon, svg) { + width: 22px; + height: 22px; + margin: 0; + font-size: 22px; + } + } + + > ion-menu-button.ios.ios-theme-foldable-toolbar-projection { + @include api.glass-control-background; + border-radius: 50%; + } + + > ion-button.ios.ios-theme-foldable-toolbar-projection { + --background: rgba(var(--ios-theme-glass-background-rgb, var(--ios26-glass-background-rgb)), 0.72); + --border-radius: 50%; + @include glass.light-shadow('--box-shadow'); + + &::part(native) { + @include api.glass-control-background($include-background: false, $shadow: var(--box-shadow)); + } + } + + > ion-buttons.ios.ios-theme-foldable-toolbar-projection { + display: flex; + flex-direction: column; + height: auto; + min-height: 0; + + > :is(ion-button, ion-menu-button).ios.ios-theme-foldable-toolbar-action { + display: block; + width: 44px; + min-width: 44px; + height: 46px; + min-height: 46px; + margin: 0 1px; + + &::part(native) { + margin-inline: auto; + } + } + } +}