Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
79c9390
feat(native): project foldable controls with SwiftUI
rdlabo Sep 22, 2026
e4f2068
test: align native shell ownership assertions
rdlabo Sep 22, 2026
b9ff6bb
fix(native): preserve foldable control ownership
rdlabo Sep 22, 2026
69bdf05
fix(native): gate foldable rail ownership by capability
rdlabo Sep 22, 2026
df1885d
fix(demo): refresh native shell for foldable mode
rdlabo Sep 22, 2026
3d8b357
fix(native): adapt foldable toolbar on mode change
rdlabo Sep 22, 2026
48e7488
fix(native): project foldable toolbar without restart
rdlabo Sep 22, 2026
b92cdb7
fix(native): keep foldable rail active with menu
rdlabo Sep 22, 2026
9cd18cd
fix(native): retain foldable rail controls with menu
rdlabo Sep 22, 2026
d1d7710
fix(native): avoid duplicate foldable toolbar projection
rdlabo Sep 22, 2026
eff2832
test(native): exercise foldable rail interactions
rdlabo Sep 22, 2026
a6aad83
fix(native): preserve foldable rail menu interactions
rdlabo Sep 22, 2026
7a81d1e
fix(native): align foldable shell projection
rdlabo Sep 22, 2026
5541ba1
fix(native): prehide foldable toolbar sources
rdlabo Sep 23, 2026
62b3b18
fix(foldable): hand off toolbar controls without transition flash
rdlabo Sep 23, 2026
bc31ca7
fix(foldable): keep departed toolbar sources retired through transitions
rdlabo Sep 23, 2026
47fed29
fix(foldable): project vertical controls at navigation start
rdlabo Sep 23, 2026
ae752a5
fix(foldable): preserve author-disabled actions during entry
rdlabo Sep 23, 2026
76f77ff
fix(foldable): preserve rail eligibility and author visibility
rdlabo Sep 23, 2026
adb65a4
test(foldable): trim redundant native shell coverage
rdlabo Sep 23, 2026
0bcb634
refactor(native): share foldable toolbar button rendering
rdlabo Sep 23, 2026
3fe257d
refactor: share foldable page projection lifecycle
rdlabo Sep 23, 2026
1fa1c34
feat: preserve foldable toolbar start and end groups
rdlabo Sep 23, 2026
6a62390
feat: use system back navigation in foldable rail
rdlabo Sep 23, 2026
d45327c
fix: restore foldable toolbar and label overflow actions
rdlabo Sep 23, 2026
c282662
test: verify foldable native rail navigation and labels
rdlabo Sep 23, 2026
bc7e590
fix: keep foldable transition shade outside control rail
rdlabo Sep 23, 2026
0c37711
fix: remove foldable page transition shade
rdlabo Sep 23, 2026
37e67cc
fix: keep foldable native rail backing clear during navigation
rdlabo Sep 23, 2026
09ddbc2
fix: retain foldable back controls until page departure
rdlabo Sep 24, 2026
4f59765
fix: suppress foldable rail dimming without disabling glass motion
rdlabo Sep 24, 2026
b586520
fix(ios): project foldable back without native navigation pop
rdlabo Sep 24, 2026
6ef8717
fix(foldable): switch projected controls at will lifecycle
rdlabo Sep 24, 2026
bfaf428
fix(ios): animate projected foldable toolbar changes
rdlabo Sep 24, 2026
d71fe0d
fix(ios): use system back symbol in foldable rail
rdlabo Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions demo/e2e/animation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ test.describe('Animation Tests', () => {
await expect(shade).toHaveCount(0);
});

test('foldable page transition keeps its shade outside the control rail', async ({ page }) => {
await page.goto('/main/index', { waitUntil: 'networkidle' });
await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable'));
await page.getByRole('button', { name: 'button', exact: true }).click();
const shade = page.locator('.ios-transition-shade');
await expect(shade).toBeVisible();
await page.evaluate(() => document.getAnimations().forEach((animation) => animation.pause()));
await expect(shade).toHaveCSS('clip-path', 'inset(0px 80px 0px 0px)');
await page.evaluate(() => document.getAnimations().forEach((animation) => animation.play()));
});

test('runs and completes the iOS popover animations', async ({ page }) => {
await page.goto('/main/index/popover', { waitUntil: 'networkidle' });
await clearAnimationCalls(page);
Expand Down
67 changes: 67 additions & 0 deletions demo/e2e/foldable-back-button.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,73 @@ test('Native UI Shell suspension synchronously restores and resumes foldable own
await expect(projection).toBeVisible();
});

test('a stale suspension lease cannot re-hide Web controls after shell teardown', async ({ page }) => {
await page.goto('/main/index/button');
await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-enable-foldable'));
const source = page.locator('app-button ion-header ion-back-button').first();
await expect(page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection')).toBeVisible();

await page.evaluate(async () => {
const shell = (window as any).nativeUIShell;
const lease = await shell.suspend();
await shell.destroy();
await lease.resume();
});
await expect(source).toBeVisible();
await expect(page.locator('html')).not.toHaveClass(/ios-theme-native-ui-shell-prehide/);
});

test('Web toolbar projection switches at WillLeave and restores on cancellation', async ({ page }) => {
await page.goto('/main/index/button');
await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-enable-foldable'));
const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection');
await expect(projection).toBeVisible();
const routedPage = page.locator('app-button.ion-page');

await routedPage.evaluate((element) => {
element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true }));
});
await expect(projection).toHaveCount(0);
await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true })));
await page.waitForTimeout(150);
await expect(projection).toHaveCount(0);

await routedPage.evaluate((element) => {
element.classList.add('ion-page-invisible');
element.dispatchEvent(new CustomEvent('ionViewWillEnter', { bubbles: true }));
});
await expect(projection).toBeVisible();
await routedPage.evaluate((element) => {
element.classList.remove('ion-page-invisible');
element.dispatchEvent(new CustomEvent('ionViewDidEnter', { bubbles: true }));
});

await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true })));
await expect(projection).toHaveCount(0);
await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeFoldableTransitionCanceled', { bubbles: true })));
await expect(projection).toBeVisible();
});

test('turning foldable on during a transition honors its success or cancellation', async ({ page }) => {
await page.goto('/main/index/button');
const app = page.locator('ion-app');
await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable'));
const routedPage = page.locator('app-button.ion-page');
const projection = page.locator('ion-app > ion-back-button.ios-theme-foldable-back-button-projection');

await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true })));
await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable'));
await expect(projection).toHaveCount(0);
await routedPage.evaluate((element) => element.dispatchEvent(new Event('iosThemeFoldableTransitionCanceled', { bubbles: true })));
await expect(projection).toBeVisible();

await app.evaluate((element) => element.classList.remove('ios-theme-enable-foldable'));
await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: true })));
await app.evaluate((element) => element.classList.add('ios-theme-enable-foldable'));
await routedPage.evaluate((element) => element.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: true })));
await expect(projection).toHaveCount(0);
});

test('foldable projection respects shell opt-out and iOS mode', async ({ page }) => {
await page.goto('/main/index/button');
const app = page.locator('ion-app');
Expand Down
32 changes: 24 additions & 8 deletions demo/e2e/foldable-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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.setViewportSize({ width: 390, height: 844 });
await page.goto('/main/index', { waitUntil: 'networkidle' });
const menu = page.locator('ion-menu');

Expand All @@ -11,8 +12,11 @@ for (const direction of ['ltr', 'rtl'] as const) {
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;
const container = element.shadowRoot!.querySelector<HTMLElement>('[part~="container"]')!;
await element.open(false);
const defaultHostBounds = element.getBoundingClientRect();
const defaultRightOffset = innerWidth - container.getBoundingClientRect().right;
await element.close(false);
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');
Expand All @@ -21,9 +25,17 @@ for (const direction of ['ltr', 'rtl'] as const) {
for (const side of ['start', 'end'] as const) {
element.side = side;
await new Promise(requestAnimationFrame);
await element.open(false);
const bounds = element.getBoundingClientRect();
const containerBounds = container.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 });
offsets.push({
side,
physicalSide,
hostWidthPreserved: bounds.width > 0 && bounds.width === defaultHostBounds.width,
offset: physicalSide === 'left' ? containerBounds.left : innerWidth - containerBounds.right,
});
await element.close(false);
}
const contentStyle = getComputedStyle(element.querySelector('ion-content')!);

Expand All @@ -38,6 +50,7 @@ for (const direction of ['ltr', 'rtl'] as const) {
await new Promise(requestAnimationFrame);
if (!modalContent.classList.contains('ios')) throw new Error('Expected an iOS ion-content fixture');
return {
defaultHostWidthPositive: defaultHostBounds.width > 0,
defaultRightOffset,
offsets,
safeAreaLeft: contentStyle.getPropertyValue('--ion-safe-area-left').trim(),
Expand All @@ -46,16 +59,19 @@ for (const direction of ['ltr', 'rtl'] as const) {
};
}, direction);

expect(result.defaultRightOffset).toBe(80);
expect({ hostWidthPositive: result.defaultHostWidthPositive, containerRight: result.defaultRightOffset }).toEqual({
hostWidthPositive: true,
containerRight: 80,
});
expect(result.offsets).toEqual(
direction === 'ltr'
? [
{ side: 'start', physicalSide: 'left', offset: 76 },
{ side: 'end', physicalSide: 'right', offset: 84 },
{ side: 'start', physicalSide: 'left', hostWidthPreserved: true, offset: 76 },
{ side: 'end', physicalSide: 'right', hostWidthPreserved: true, offset: 84 },
]
: [
{ side: 'start', physicalSide: 'right', offset: 84 },
{ side: 'end', physicalSide: 'left', offset: 76 },
{ side: 'start', physicalSide: 'right', hostWidthPreserved: true, offset: 84 },
{ side: 'end', physicalSide: 'left', hostWidthPreserved: true, offset: 76 },
],
);
expect(result.safeAreaLeft).toBe('0px');
Expand Down
Loading