Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ Use this markup to preview the inset grouped list look. For the list structure t

Keep your existing Ionic theme and move tabs and supported toolbar actions into a vertical side area. **Start in Chrome** with one stylesheet, an app class, and `enableVerticalControlArea()`; then connect the layout to iPhone Duo device events for the system rail and hinge posture.

Device state is supplied by [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable), installed in your app:

```bash
npm install @erkamyaman/capacitor-foldable
npx cap sync
```

Use its `getBarPlacement()` / `barPlacementChange` and `getFoldState()` / `foldStateChange` APIs to drive the theme's layout. Device monitoring is not bundled with the theme.

Follow [iPhone Duo with your existing theme](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo-with-original-theme) for the browser preview and iOS setup. For shared layout rules and APIs, see [iPhone Duo support](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo). Available in `1.2.0-0` as an experimental feature; APIs and supported behavior may change.

### Use only the iOS 27 theme
Expand Down
11 changes: 11 additions & 0 deletions demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@ This is an Angular-based demo application for the Ionic iOS27 Theme Library.
npm install
```

### iPhone Duo device state

The demo depends on [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable) for bar placement and fold state. `npm install` installs it; after building, run `npx cap sync ios` to register it in the iOS app. For your own app:

```bash
npm install @erkamyaman/capacitor-foldable
npx cap sync
```

Use Capacitor 8.5+ and Xcode 27.1+ for iPhone Duo on iOS 27.1+. The Vertical Bars toggle still allows a browser preview when no native edge is reported.

### Development Server

```bash
Expand Down
23 changes: 13 additions & 10 deletions demo/e2e/native-ui-shell-edge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,6 @@ const mockNative = async (page: Page) => {
async getWebViewMetrics() {
return { radius: 0 };
},
async getDeviceLayout() {
return {
placement: { edge: 'trailing' as const, inset: 84 },
hingeStatus: null,
webViewMetrics: { radius: 0 },
};
},
async startDeviceLayoutMonitoring() {},
async stopDeviceLayoutMonitoring() {},
async update(options: ShellSnapshot) {
this.updates.push(options);
return this.settle(options);
Expand All @@ -84,6 +75,18 @@ const mockNative = async (page: Page) => {
},
};

const foldable = {
async getBarPlacement() {
return { verticalBarEdge: 'trailing' };
},
async getFoldState() {
return { state: 'flat', isSeparating: false, posture: 'flat' };
},
listeners: {} as Record<string, ((event: never) => void)[]>,
addListener: mock.addListener,
notifyListeners: mock.notifyListeners,
};

window.CapacitorCustomPlatform = { name: 'ios' };
// Substitute the mock as the plugin implementation when @capacitor/core
// initialises its global, before the app registers 'IonicNativeUIShell'.
Expand All @@ -94,7 +97,7 @@ const mockNative = async (page: Page) => {
set: (instance) => {
const registerPlugin = instance.registerPlugin;
instance.registerPlugin = (name: string, implementations?: Record<string, unknown>) =>
name === 'IonicNativeUIShell' ? mock : registerPlugin(name, implementations);
name === 'IonicNativeUIShell' ? mock : name === 'Foldable' ? foldable : registerPlugin(name, implementations);
capacitor = instance;
},
});
Expand Down
172 changes: 138 additions & 34 deletions demo/e2e/native-ui-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ interface ShellMock extends ShellMockCore {
retirementDetails: { path: string; tabs: string }[];
}

const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'trailing' | null = 'trailing') => {
const script = ([fail, nativeEdge]: readonly [boolean, 'leading' | 'trailing' | null]) => {
const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'trailing' | null | 'unreported' = 'trailing') => {
const script = ([fail, nativeEdge]: readonly [boolean, 'leading' | 'trailing' | null | 'unreported']) => {
const mock = {
updates: [] as ShellSnapshot[],
sequence: 0,
Expand Down Expand Up @@ -53,15 +53,6 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra
async getWebViewMetrics() {
return { radius: 0 };
},
async getDeviceLayout() {
return {
placement: { edge: nativeEdge, inset: nativeEdge ? 84 : 0 },
hingeStatus: null,
webViewMetrics: { radius: 0 },
};
},
async startDeviceLayoutMonitoring() {},
async stopDeviceLayoutMonitoring() {},
async update(options: ShellSnapshot) {
this.updates.push(options);
if (this.hang) await new Promise(() => {});
Expand Down Expand Up @@ -91,6 +82,19 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra
},
};

const foldable = {
async getBarPlacement() {
if (nativeEdge === 'unreported') return new Promise(() => {});
return { verticalBarEdge: nativeEdge, inset: nativeEdge ? 84 : 0 };
},
async getFoldState() {
return { state: 'flat', isSeparating: false, posture: 'flat' };
},
listeners: {} as Record<string, ((event: never) => void)[]>,
addListener: mock.addListener,
notifyListeners: mock.notifyListeners,
};

window.CapacitorCustomPlatform = { name: 'ios' };
// Substitute the mock as the plugin implementation when @capacitor/core
// initialises its global, before the app registers 'IonicNativeUIShell'.
Expand All @@ -101,7 +105,7 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra
set: (instance) => {
const registerPlugin = instance.registerPlugin;
instance.registerPlugin = (name: string, implementations?: Record<string, unknown>) =>
name === 'IonicNativeUIShell' ? mock : registerPlugin(name, implementations);
name === 'IonicNativeUIShell' ? mock : name === 'Foldable' ? foldable : registerPlugin(name, implementations);
capacitor = instance;
},
});
Expand Down Expand Up @@ -816,8 +820,7 @@ test('verticalBars rail remains native while its Ionic menu is open', async ({ p
await expect(morphedCancel).toBeVisible();
});

// An OS-reported edge that disagrees with the DOM strip is the only supported
// "native rail unavailable" state; the Web fallback then owns the rail.
// A renderer that cannot honor the requested edge hands the rail back to the Web.
test('verticalBars controls stay operable on Web when the reported rail edge differs', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockNative(page, false, 'leading');
Expand Down Expand Up @@ -848,9 +851,9 @@ test('verticalBars controls stay operable on Web when the reported rail edge dif
await expect
.poll(() =>
page.evaluate(() =>
Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell').updates.every((snapshot: ShellSnapshot) =>
snapshot.controls.every((control: ShellControl) => control.placement !== 'vertical-bars'),
),
Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell')
.updates.at(-1)
?.controls.every((control: ShellControl) => control.placement !== 'vertical-bars'),
),
)
.toBe(true);
Expand All @@ -877,28 +880,102 @@ test('verticalBars controls stay operable on Web when the reported rail edge dif
expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).verticalBarsBackCloneMoved)).toBe(false);
});

// Apps linked against an SDK older than 27.1 never get a trait-reported edge;
// the DOM strip still owns the layout, so the native rail follows it.
test('verticalBars controls project natively when the OS reports no rail edge', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockNative(page, false, null);
await page.goto('/main/index/native-ui-shell');
await page.locator('app-native-ui-shell ion-menu-button').evaluate((element: HTMLIonMenuButtonElement) => (element.autoHide = false));
await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
test('reported rail inset updates the layout without an edge change and clears when unavailable', async ({ page }) => {
await mockNative(page);
await page.goto('/main/index?verticalBarsOnly');
const app = page.locator('ion-app');
await page.getByText('iPhone Duo Mode', { exact: true }).click();
const width = () => app.evaluate((element) => element.style.getPropertyValue('--ios-theme-vertical-bars-native-inset'));
await expect.poll(width).toBe('84px');

const report = async (verticalBarEdge: 'leading' | 'trailing' | null, inset: number) => {
await page.evaluate(
({ verticalBarEdge, inset }) => {
Capacitor.registerPlugin<ShellMock>('Foldable').notifyListeners('barPlacementChange', { verticalBarEdge, inset });
},
{ verticalBarEdge, inset },
);
};
await report('trailing', 64);
await expect.poll(width).toBe('64px');
await expect
.poll(() =>
page.evaluate(() =>
Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell')
.updates.at(-1)
?.controls.some((control: ShellControl) => control.placement === 'vertical-bars'),
),
app.evaluate((element) => getComputedStyle(element).getPropertyValue('--ios-theme-vertical-bars-safe-area-right-resolved').trim()),
)
.toBe(true);
await expect(page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection')).toHaveCount(0);
await expect(page.locator('ion-app > ion-menu-button.ios-theme-vertical-bars-toolbar-projection')).toHaveCount(0);
await expect(page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label=Save]')).toHaveCount(0);
.toBe('64px');
await report('leading', 96);
await expect(app).toHaveClass(/ios-theme-vertical-bars-left/);
await expect.poll(width).toBe('96px');
await report(null, 0);
await expect.poll(width).toBe('');
await expect(app).toHaveClass(/ios-theme-vertical-bars/);
await expect(page.locator('ion-tab-bar')).toBeVisible();
});

for (const initialEdge of [null, 'unreported'] as const) {
for (const verticalBarsOnly of [true, false]) {
test(`${initialEdge ?? 'null'} native edge restores ${verticalBarsOnly ? 'Web rail' : 'ordinary Native UI Shell'} and recovers`, async ({
page,
}) => {
await page.setViewportSize({ width: 700, height: 900 });
await mockNative(page, false, initialEdge);
await page.goto(`/main/index/native-ui-shell${verticalBarsOnly ? '?verticalBarsOnly' : ''}`);
await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => {
icon.setAttribute('slot', 'icon-only');
icon.parentElement!.querySelector('[data-label]')?.remove();
});
const app = page.locator('ion-app');
const tabs = page.locator('ion-tab-bar');
const save = page.locator('app-native-ui-shell ion-button[type=submit]');
const clone = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label=Save]');
const snapshot = () => page.evaluate(() => Capacitor.registerPlugin<ShellMock>('IonicNativeUIShell').updates.at(-1));
const reportEdge = async (verticalBarEdge: 'leading' | 'trailing' | null) => {
await page.evaluate((edge) => {
Capacitor.registerPlugin<ShellMock>('Foldable').notifyListeners('barPlacementChange', { verticalBarEdge: edge });
}, verticalBarEdge);
};
await app.evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
const expectFallback = async () => {
await expect.poll(async () => (await snapshot())?.controls.every((control) => control.placement !== 'vertical-bars')).toBe(true);
if (verticalBarsOnly) {
await expect(app).toHaveClass(/ios-theme-vertical-bars/);
await expect(clone).toBeVisible();
await expect(tabs).not.toHaveAttribute('data-native-ui-shell', '');
await expect(tabs).toBeVisible();
} else {
await expect(app).not.toHaveClass(/(?:^| )ios-theme-vertical-bars(?: |$)/);
await expect(app).toHaveAttribute('data-native-ui-shell-vertical-bars-suspended', '');
await expect(clone).toHaveCount(0);
await expect(tabs).toHaveAttribute('data-native-ui-shell', '');
await expect(save).toHaveAttribute('data-native-ui-shell', '');
await expect.poll(async () => (await snapshot())?.controls.some((control) => control.kind === 'ion-tab-bar')).toBe(true);
}
};
await expectFallback();
if (verticalBarsOnly) await clone.click();
else await activate(page, 'Save');
await expect(page.locator('[data-save-count]')).toHaveText('1');

await reportEdge('trailing');
await expect(app).toHaveClass(/ios-theme-vertical-bars/);
await expect(app).not.toHaveAttribute('data-native-ui-shell-vertical-bars-suspended', '');
await expect.poll(async () => (await snapshot())?.controls.some((control) => control.placement === 'vertical-bars')).toBe(true);
await expect(tabs).toHaveAttribute('data-native-ui-shell', '');
await expect(clone).toHaveCount(0);

await reportEdge(null);
await expectFallback();
if (verticalBarsOnly) await clone.click();
else await activate(page, 'Save');
await expect(page.locator('[data-save-count]')).toHaveText('2');

// The last explicit null survives updates and layout changes.
await page.setViewportSize({ width: 740, height: 900 });
await expectFallback();
});
}
}

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 @@ -2781,6 +2858,33 @@ test('rejected search retries when tab content changes without resizing', async
expect(await page.locator('ion-tab-bar').boundingBox()).toEqual(before);
});

test('verticalBars return to native projection when the requested edge matches after rotation', async ({ page }) => {
await mockNative(page, false, 'leading');
await page.goto('/main/index/native-ui-shell');
await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => {
icon.setAttribute('slot', 'icon-only');
icon.parentElement!.querySelector('[data-label]')?.remove();
});
await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars'));
const source = page.locator('app-native-ui-shell ion-button[type=submit]');
const clone = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label=Save]');
await expect(clone).toBeVisible();
const rotate = () =>
page.evaluate(() => {
Capacitor.registerPlugin<{ notifyListeners(name: string, value: unknown): void }>('Foldable').notifyListeners('barPlacementChange', {
verticalBarEdge: 'trailing',
});
});
await rotate();
await expect(clone).toHaveCount(0);
await expect(source).toHaveAttribute('data-native-ui-shell', '');
await page.locator('ion-app').evaluate((element) => element.setAttribute('dir', 'rtl'));
await expect(clone).toBeVisible();
await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars-left'));
await expect(clone).toHaveCount(0);
await expect(source).toHaveAttribute('data-native-ui-shell', '');
});

for (const type of ['normal', 'card', 'sheet']) {
test(`verticalBars native controls follow the foreground ${type} modal`, async ({ page }) => {
await page.setViewportSize({ width: 700, height: 900 });
Expand Down
2 changes: 2 additions & 0 deletions demo/ios/App/CapApp-SPM/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ let package = Package(
.package(name: "CapacitorHaptics", path: "../../../node_modules/@capacitor/haptics"),
.package(name: "CapacitorKeyboard", path: "../../../node_modules/@capacitor/keyboard"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/@capacitor/status-bar"),
.package(name: "ErkamyamanCapacitorFoldable", path: "../../../node_modules/@erkamyaman/capacitor-foldable"),
.package(name: "RdlaboIonicThemeIos27", path: "../../../node_modules/@rdlabo/ionic-theme-ios27")
],
targets: [
Expand All @@ -28,6 +29,7 @@ let package = Package(
.product(name: "CapacitorHaptics", package: "CapacitorHaptics"),
.product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar"),
.product(name: "ErkamyamanCapacitorFoldable", package: "ErkamyamanCapacitorFoldable"),
.product(name: "RdlaboIonicThemeIos27", package: "RdlaboIonicThemeIos27")
]
)
Expand Down
16 changes: 15 additions & 1 deletion demo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@capacitor/ios": "^8.0.0",
"@capacitor/keyboard": "^8.0.0",
"@capacitor/status-bar": "^8.0.0",
"@erkamyaman/capacitor-foldable": "^8.3.3",
"@ionic/angular": "^9.0.0",
"@rdlabo/ionic-theme-ios26": "^9.3.0",
"@rdlabo/ionic-theme-ios27": "file:..",
Expand Down
Loading
Loading