Skip to content
Closed
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
115 changes: 115 additions & 0 deletions demo/e2e/foldable-back-button.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
44 changes: 44 additions & 0 deletions demo/e2e/native-ui-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<HTMLIonButtonElement>('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 });
Expand Down
39 changes: 39 additions & 0 deletions demo/e2e/screenshot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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' });
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-ltr.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/foldable-layout-rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/foldable-split-pane-ltr.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified demo/e2e/screenshot.spec.ts-snapshots/foldable-split-pane-rtl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion demo/src/app/docs/docs-content.generated.ts

Large diffs are not rendered by default.

17 changes: 7 additions & 10 deletions demo/src/app/native-ui-shell/native-ui-shell.page.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<ion-buttons slot="start"><ion-back-button defaultHref="/main/index"></ion-back-button></ion-buttons>
<ion-title>Native UI Shell</ion-title>
<ion-buttons slot="end" [style.display]="hidden() ? 'none' : null" [class.ionic-theme-disabled]="themeDisabled()">
<ion-button fill="clear" type="button">Cancel</ion-button>
<ion-button [fill]="fill()" type="submit" [form]="formRef" [disabled]="disabled()" aria-label="Save">
<ion-icon slot="start" name="heart" aria-hidden="true"></ion-icon>
<span data-label>Save</span>
Expand Down Expand Up @@ -32,28 +33,24 @@
<ion-content [fullscreen]="true">
<section class="controls">
<form #formRef (submit)="save($event)">
<p>
Save count: <strong data-save-count>{{ saves() }}</strong>
</p>
<p>Save count: <strong data-save-count>{{ saves() }}</strong></p>
</form>
<p>Experimental: On supported iOS versions, fixed glass controls are rendered natively.</p>
<p>
Selection: <strong data-selection>{{ selected() }}</strong> / Change count: <strong data-changes>{{ changes() }}</strong>
</p>
<p>Selection: <strong data-selection>{{ selected() }}</strong> / Change count: <strong data-changes>{{ changes() }}</strong></p>
<button type="button" (click)="selected.set('one')">Select One programmatically</button>
<button type="button" (click)="modalOpen.set(true)">Open modal</button>
<p>Scroll the background to compare transparency and refraction in the top-right button.</p>
<button type="button" (click)="disabled.set(!disabled())">disabled: {{ disabled() }}</button>
<button type="button" (click)="hidden.set(!hidden())">Parent hidden: {{ hidden() }}</button>
<button type="button" (click)="themeDisabled.set(!themeDisabled())">Theme disabled: {{ themeDisabled() }}</button>
@for (value of fills; track value) {
<button type="button" (click)="fill.set(value)">fill: {{ value }}</button>
<button type="button" (click)="fill.set(value)">fill: {{ value }}</button>
}
</section>
@for (band of bands; track band) {
<div class="band" [class.alternate]="band % 2 === 0">
<span>{{ band }} — GLASS / Glass / 123456789</span>
</div>
<div class="band" [class.alternate]="band % 2 === 0">
<span>{{ band }} — GLASS / Glass / 123456789</span>
</div>
}
</ion-content>
<ion-modal [isOpen]="modalOpen()" (didDismiss)="modalOpen.set(false)">
Expand Down
36 changes: 36 additions & 0 deletions demo/src/foldable-web.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expect, test } from 'vitest';
import { createFoldableWebProjection } from '../../src/native/foldable-web';

const mountEligibleBackButton = () => {
document.body.innerHTML = `
<ion-app class="ios-theme-enable-foldable">
<main class="ion-page">
<ion-header><ion-toolbar class="ios"><ion-back-button class="ios"></ion-back-button></ion-toolbar></ion-header>
</main>
</ion-app>`;
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<void>((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();
});
4 changes: 4 additions & 0 deletions docs/special-markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions src/native/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down
Loading