From ecc3dab72afe8374f2a981e6c62a8b207b775e03 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 27 Sep 2026 10:14:27 +0900 Subject: [PATCH 01/12] refactor: delegate device state to capacitor-foldable --- README.md | 9 + demo/README.md | 11 ++ demo/e2e/native-ui-shell-edge.spec.ts | 23 ++- demo/e2e/native-ui-shell.spec.ts | 58 ++++-- demo/ios/App/CapApp-SPM/Package.swift | 2 + demo/package-lock.json | 16 +- demo/package.json | 1 + demo/src/app/index/index-page.component.ts | 9 +- demo/src/app/tabs/tabs.page.spec.ts | 51 +++++ demo/src/app/tabs/tabs.page.ts | 26 +-- demo/src/main.ts | 8 +- docs/iphone-duo-with-original-theme.md | 23 ++- docs/iphone-duo.md | 79 ++++---- .../IonicNativeUIShellPlugin.swift | 181 +----------------- src/native/definitions.ts | 19 +- src/native/index.ts | 42 ++-- src/vertical-bars.ts | 3 +- 17 files changed, 242 insertions(+), 319 deletions(-) diff --git a/README.md b/README.md index 823fcba4..b25055b8 100644 --- a/README.md +++ b/README.md @@ -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@^8.3.1 +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 diff --git a/demo/README.md b/demo/README.md index e30f6537..7793fd53 100644 --- a/demo/README.md +++ b/demo/README.md @@ -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@^8.3.1 +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 diff --git a/demo/e2e/native-ui-shell-edge.spec.ts b/demo/e2e/native-ui-shell-edge.spec.ts index a7a525ed..37e98323 100644 --- a/demo/e2e/native-ui-shell-edge.spec.ts +++ b/demo/e2e/native-ui-shell-edge.spec.ts @@ -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); @@ -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 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'. @@ -94,7 +97,7 @@ const mockNative = async (page: Page) => { set: (instance) => { const registerPlugin = instance.registerPlugin; instance.registerPlugin = (name: string, implementations?: Record) => - name === 'IonicNativeUIShell' ? mock : registerPlugin(name, implementations); + name === 'IonicNativeUIShell' ? mock : name === 'Foldable' ? foldable : registerPlugin(name, implementations); capacitor = instance; }, }); diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 56058a0b..efc7fd3c 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -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(() => {}); @@ -91,6 +82,18 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra }, }; + const foldable = { + async getBarPlacement() { + return { verticalBarEdge: nativeEdge }; + }, + async getFoldState() { + return { state: 'flat', isSeparating: false, posture: 'flat' }; + }, + listeners: {} as Record 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'. @@ -101,7 +104,7 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra set: (instance) => { const registerPlugin = instance.registerPlugin; instance.registerPlugin = (name: string, implementations?: Record) => - name === 'IonicNativeUIShell' ? mock : registerPlugin(name, implementations); + name === 'IonicNativeUIShell' ? mock : name === 'Foldable' ? foldable : registerPlugin(name, implementations); capacitor = instance; }, }); @@ -796,11 +799,10 @@ 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. -test('verticalBars controls stay operable on Web when the reported rail edge differs', async ({ page }) => { +// If native projection fails, the Web fallback still owns the application-selected rail. +test('verticalBars controls stay operable on Web when native projection fails', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); - await mockNative(page, false, 'leading'); + await mockNative(page, true, 'leading'); 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')); @@ -2756,3 +2758,31 @@ test('rejected search retries when tab content changes without resizing', async await expect(footer).toHaveAttribute('data-native-ui-shell', ''); expect(await page.locator('ion-tab-bar').boundingBox()).toEqual(before); }); + +test('demo applies Foldable placement and fold events while keeping the rail toggle app-owned', async ({ page }) => { + await mockNative(page, false, 'leading'); + await page.goto('/main/index'); + const app = page.locator('ion-app'); + const toggle = page.getByRole('switch', { name: 'iPhone Duo Mode' }); + await toggle.click(); + await expect(app).toHaveClass(/ios-theme-vertical-bars-left/); + await page.evaluate(() => { + const foldable = Capacitor.registerPlugin<{ notifyListeners(name: string, value: unknown): void }>('Foldable'); + foldable.notifyListeners('barPlacementChange', { verticalBarEdge: 'trailing' }); + foldable.notifyListeners('foldStateChange', { + state: 'half-opened', + posture: 'book', + isSeparating: true, + hingeBounds: { x: 450, y: 0, width: 0, height: 900 }, + }); + }); + await expect(app).not.toHaveClass(/ios-theme-vertical-bars-left/); + await expect(page.locator('ion-split-pane')).toHaveClass(/ios-theme-split-pane-half-open/); + await toggle.click(); + await page.evaluate(() => + Capacitor.registerPlugin<{ notifyListeners(name: string, value: unknown): void }>('Foldable').notifyListeners('barPlacementChange', { + verticalBarEdge: 'leading', + }), + ); + await expect(app).not.toHaveClass(/ios-theme-vertical-bars/); +}); diff --git a/demo/ios/App/CapApp-SPM/Package.swift b/demo/ios/App/CapApp-SPM/Package.swift index 78efdd00..7fd2f053 100644 --- a/demo/ios/App/CapApp-SPM/Package.swift +++ b/demo/ios/App/CapApp-SPM/Package.swift @@ -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: [ @@ -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") ] ) diff --git a/demo/package-lock.json b/demo/package-lock.json index 775a0e89..c896ca4e 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -22,6 +22,7 @@ "@capacitor/ios": "^8.0.0", "@capacitor/keyboard": "^8.0.0", "@capacitor/status-bar": "^8.0.0", + "@erkamyaman/capacitor-foldable": "^8.3.1", "@ionic/angular": "^9.0.0", "@rdlabo/ionic-theme-ios26": "^9.3.0", "@rdlabo/ionic-theme-ios27": "file:..", @@ -56,7 +57,7 @@ }, "..": { "name": "@rdlabo/ionic-theme-ios27", - "version": "1.0.4", + "version": "1.2.0-1", "license": "MIT", "dependencies": { "@rdlabo/ionic-theme-utils": "0.1.1" @@ -1360,6 +1361,19 @@ "tslib": "^2.4.0" } }, + "node_modules/@erkamyaman/capacitor-foldable": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@erkamyaman/capacitor-foldable/-/capacitor-foldable-8.3.1.tgz", + "integrity": "sha512-cpuM2nKfzNZT+7ZzTbjzBlkK2Skoff2gdn7yVDy2gbB0dJtZKxCMYWq8rzVbtS2lqMT2OZCtRcgqsvDd+AWIyg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/erkamyaman" + }, + "peerDependencies": { + "@capacitor/core": "^8.0.0" + } + }, "node_modules/@es-joy/jsdoccomment": { "version": "0.46.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.46.0.tgz", diff --git a/demo/package.json b/demo/package.json index 5a46c8a6..6d60ceee 100644 --- a/demo/package.json +++ b/demo/package.json @@ -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.1", "@ionic/angular": "^9.0.0", "@rdlabo/ionic-theme-ios26": "^9.3.0", "@rdlabo/ionic-theme-ios27": "file:..", diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index c5c7dfda..2f4c32a4 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -19,8 +19,8 @@ import { ToggleCustomEvent, } from '@demo/ionic'; import { ActivatedRoute, Router } from '@angular/router'; -import { IonicNativeUIShell, setVerticalControlAreaPlacement } from '@rdlabo/ionic-theme-ios27/vertical-bars'; -import { Capacitor } from '@capacitor/core'; +import { setVerticalControlAreaPlacement } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { Foldable } from '@erkamyaman/capacitor-foldable'; interface IComponent { name: string; @@ -104,8 +104,7 @@ export class IndexPageComponent { async changeVerticalBarsMode(event: ToggleCustomEvent) { if (!event.detail.checked) return setVerticalControlAreaPlacement(null); - const placement = - Capacitor.getPlatform() === 'ios' ? (await IonicNativeUIShell.getDeviceLayout()).placement : ({ edge: null, inset: 0 } as const); - setVerticalControlAreaPlacement(placement.edge ? placement : 'trailing'); + const { verticalBarEdge } = await Foldable.getBarPlacement(); + setVerticalControlAreaPlacement(verticalBarEdge ?? 'trailing'); } } diff --git a/demo/src/app/tabs/tabs.page.spec.ts b/demo/src/app/tabs/tabs.page.spec.ts index 0ce47adc..9489a188 100644 --- a/demo/src/app/tabs/tabs.page.spec.ts +++ b/demo/src/app/tabs/tabs.page.spec.ts @@ -1,5 +1,13 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Capacitor } from '@capacitor/core'; +import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable'; +import { vi } from 'vitest'; + +vi.mock('@erkamyaman/capacitor-foldable', () => ({ + Foldable: { addListener: vi.fn(), getFoldState: vi.fn() }, +})); + import { TabsPage } from './tabs.page'; import { testConfig } from '../../../util/test.config'; @@ -20,7 +28,50 @@ describe('TabsPage', () => { fixture.detectChanges(); }); + afterEach(() => vi.restoreAllMocks()); + it('should create', () => { expect(component).toBeTruthy(); }); + + it('applies the initial fold, updates the pane, and removes its listener on destroy', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + const remove = vi.fn().mockResolvedValue(undefined); + let emit!: (fold: FoldState) => void; + vi.mocked(Foldable.addListener).mockImplementation(async (_event, listener) => { + emit = listener as unknown as (fold: FoldState) => void; + return { remove }; + }); + vi.mocked(Foldable.getFoldState).mockResolvedValue({ + state: 'half-opened', + posture: 'book', + isSeparating: true, + hingeBounds: { x: 450, y: 0, width: 0, height: 900 }, + }); + await component.observeHinge(); + const pane = fixture.nativeElement.querySelector('ion-split-pane'); + expect(pane.classList.contains('ios-theme-split-pane-half-open')).toBe(true); + expect(pane.getAttribute('when')).toBe('(min-width: 900px)'); + emit({ state: 'flat', posture: 'flat', isSeparating: false }); + expect(pane.classList.contains('ios-theme-split-pane-half-open')).toBe(false); + expect(pane.getAttribute('when')).toBe('(min-width: 992px)'); + fixture.destroy(); + expect(remove).toHaveBeenCalledTimes(1); + }); + + it('removes a listener whose registration completes after destruction', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + const remove = vi.fn().mockResolvedValue(undefined); + let registered!: (handle: { remove: typeof remove }) => void; + vi.mocked(Foldable.addListener).mockReturnValue( + new Promise((resolve) => { + registered = resolve; + }), + ); + const observing = component.observeHinge(); + fixture.destroy(); + registered({ remove }); + await observing; + expect(remove).toHaveBeenCalledTimes(1); + }); }); diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts index 65a58f94..a4cbe6b5 100644 --- a/demo/src/app/tabs/tabs.page.ts +++ b/demo/src/app/tabs/tabs.page.ts @@ -19,7 +19,7 @@ import { filter } from 'rxjs'; // import { registerTabBarEffect } from '@rdlabo/ionic-theme-ios27'; import { registeredEffect, registerTabBarEffect } from '../../../../src'; -import { HingeStatus, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable'; import { Capacitor } from '@capacitor/core'; @Component({ @@ -46,7 +46,6 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, readonly #el = inject(ElementRef); readonly splitPane = viewChild.required>('splitPane', { read: ElementRef }); #hingeListener?: { remove(): Promise }; - #hingeMonitoring = false; #destroyed = false; readonly registeredGestures: registeredEffect[] = []; ngOnInit() { @@ -64,36 +63,29 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, } ngAfterViewInit() { - void this.observeHinge(); + void this.observeHinge().catch((error) => console.error(error)); } - setHingeStatus(status: HingeStatus | null) { + setFoldState(fold: FoldState) { const splitPane = this.splitPane().nativeElement; // The width rules key off the `when` attribute, so go through setAttribute. - splitPane.setAttribute('when', status === null ? '(min-width: 992px)' : '(min-width: 900px)'); - splitPane.classList.toggle('ios-theme-split-pane-half-open', status === HingeStatus.PartiallyOpen); + splitPane.setAttribute('when', !fold.hingeBounds ? '(min-width: 992px)' : '(min-width: 900px)'); + splitPane.classList.toggle('ios-theme-split-pane-half-open', fold.state === 'half-opened'); } async observeHinge() { if (Capacitor.getPlatform() !== 'ios') return; - await IonicNativeUIShell.startDeviceLayoutMonitoring(); - this.#hingeMonitoring = true; - if (this.#destroyed) return this.#releaseHinge(); - this.#hingeListener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ hingeStatus }) => { - if (!this.#destroyed) this.setHingeStatus(hingeStatus); + this.#hingeListener = await Foldable.addListener('foldStateChange', (fold) => { + if (!this.#destroyed) this.setFoldState(fold); }); - const { hingeStatus } = await IonicNativeUIShell.getDeviceLayout(); if (this.#destroyed) return this.#releaseHinge(); - this.setHingeStatus(hingeStatus); + const fold = await Foldable.getFoldState(); + if (!this.#destroyed) this.setFoldState(fold); } #releaseHinge() { void this.#hingeListener?.remove(); this.#hingeListener = undefined; - if (this.#hingeMonitoring) { - this.#hingeMonitoring = false; - void IonicNativeUIShell.stopDeviceLayoutMonitoring(); - } } ngOnDestroy() { diff --git a/demo/src/main.ts b/demo/src/main.ts index 54014833..578f9f67 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -2,8 +2,9 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { createAppConfig, type IonicAnimationOptions } from './app/app.config'; import { AppComponent } from './app/app.component'; import { enableNativeUIShell } from '../../src/native'; -import { IonicNativeUIShell, enableVerticalControlArea, setVerticalControlAreaPlacement } from '../../src/vertical-bars'; +import { enableVerticalControlArea, setVerticalControlAreaPlacement } from '../../src/vertical-bars'; import { Capacitor } from '@capacitor/core'; +import { Foldable } from '@erkamyaman/capacitor-foldable'; import { iosTransitionAnimation, popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios27'; /** @@ -25,13 +26,12 @@ function loadIOSAnimations(): IonicAnimationOptions { void bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())) .then(async () => { if (Capacitor.getPlatform() !== 'ios') return; - await IonicNativeUIShell.startDeviceLayoutMonitoring(); - await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) => { + await Foldable.addListener('barPlacementChange', ({ verticalBarEdge }) => { const app = document.querySelector('ion-app.ios-theme-vertical-bars'); if (!app) return; const rtl = app.closest('[dir]')?.getAttribute('dir') === 'rtl'; const current = app.classList.contains('ios-theme-vertical-bars-left') !== rtl ? 'leading' : 'trailing'; - setVerticalControlAreaPlacement(placement.edge ? placement : current); + setVerticalControlAreaPlacement(verticalBarEdge ?? current); }); }) .catch((err) => console.error(err)); diff --git a/docs/iphone-duo-with-original-theme.md b/docs/iphone-duo-with-original-theme.md index 43b3c241..87b1c708 100644 --- a/docs/iphone-duo-with-original-theme.md +++ b/docs/iphone-duo-with-original-theme.md @@ -122,23 +122,30 @@ When the application owner is disposed, call `await rail.destroy()` to restore t ## Connect an iPhone Duo -For Capacitor iOS, run `npx cap sync ios`. Build with Xcode 27.1 or newer and link against the iOS 27.1 SDK or later to receive the actual rail edge, safe-area inset, and hinge posture. The plugin uses Swift Package Manager; existing CocoaPods apps can follow [Native UI Shell setup](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell#enable-the-shell). +Install [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable) for device state: + +```bash +npm install @erkamyaman/capacitor-foldable@^8.3.1 +npx cap sync ios +``` + +Use Capacitor 8.5 or later and build with Xcode 27.1 or newer for actual rail placement and hinge posture on iOS 27.1. Native UI Shell uses Swift Package Manager; existing CocoaPods apps can follow [Native UI Shell setup](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell#enable-the-shell). Keep this package's `vertical-bars.css`; the device plugin's `ionic-tabs.css` is not needed with our rail projection. Replace the browser-only startup above with this after `ion-app` is mounted: ```ts import { Capacitor, type PluginListenerHandle } from '@capacitor/core'; -import { enableVerticalControlArea, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { Foldable } from '@erkamyaman/capacitor-foldable'; const rail = await enableVerticalControlArea(); let layoutListener: PluginListenerHandle | undefined; if (Capacitor.getPlatform() === 'ios') { - // The runtime already monitors device layout; only subscribe. - layoutListener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) => - rail.setPlacement(placement), + layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge }) => + rail.setPlacement(verticalBarEdge), ); - rail.setPlacement((await IonicNativeUIShell.getDeviceLayout()).placement); + rail.setPlacement((await Foldable.getBarPlacement()).verticalBarEdge); } // Call when the application owner is disposed. @@ -148,13 +155,13 @@ const stopVerticalArea = async () => { }; ``` -`setPlacement()` applies the measured inset and resolves the logical edge through the document direction. A `null` edge restores the ordinary layout. Devices without a rail and apps built with older SDKs report `null`, so this example restores the ordinary layout there. To deliberately preview a DOM rail on such an iOS build, have your application choose a fixed edge with `rail.setPlacement('trailing')` instead of applying that null placement. This simulates the layout; it does not provide a real system rail or hinge measurements. +`setPlacement()` resolves the logical edge through the document direction. Rail width comes from the theme’s CSS safe-area rules; `Foldable` does not supply an inset. A `null` edge restores the ordinary layout. Devices without a rail and apps built with older SDKs report `null`, so this example restores the ordinary layout there. To deliberately preview a DOM rail on such an iOS build, have your application choose a fixed edge with `rail.setPlacement('trailing')` instead of applying that null placement. This simulates the layout; it does not provide a real system rail or hinge measurements. On supported iOS, controls in the rail use the system SwiftUI appearance; your custom Web styling still applies to ordinary content and horizontal controls. Web and Android use Web clones. ## Use hinge posture without projecting controls -If your existing theme needs only a posture-driven split pane or a layout switch, do not start a projection runtime or add `.ios-theme-vertical-bars`. Use `getDeviceLayout()` and `deviceLayoutChange` directly, pairing `startDeviceLayoutMonitoring()` with `stopDeviceLayoutMonitoring()` and removing the listener when finished. +If your existing theme needs only a posture-driven split pane or a layout switch, do not start a projection runtime or add `.ios-theme-vertical-bars`. Use `Foldable.getFoldState()` and `foldStateChange` directly, removing the listener when finished. There is no separate start/stop monitoring call. See [Read the device layout](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#read-the-device-layout) for the subscription example, null values, and monitoring lifetime. See [Adapt the split pane](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/iphone-duo#adapt-the-split-pane) for the opt-in width rules and half-open state. diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md index 7e91e056..853e4410 100644 --- a/docs/iphone-duo.md +++ b/docs/iphone-duo.md @@ -10,13 +10,12 @@ Adapt your Ionic app to iPhone Duo: place navigation and actions in its vertical Available in `1.2.0-0` as an **experimental** feature alongside [Native UI Shell](https://docs.rdlabo.dev/projects/ionic-theme-ios27/docs/native-ui-shell). APIs and supported behavior may change. The real system rail and hinge reporting require iOS 27.1 or later and an app built with Xcode 27.1 or newer. -This package provides three independent pieces for that hardware. Each works **without the iOS 27 theme stylesheets** and **without the full Native UI Shell**: +This package provides two independent pieces for that hardware. Each works **without the iOS 27 theme stylesheets** and **without the full Native UI Shell**: - `dist/css/vertical-bars.css` — opt-in classes that reserve the rail's safe area, plus a registered custom property for a posture-driven split-pane width. - `enableVerticalControlArea()` — moves eligible tabs and toolbar controls into the reserved area. On Capacitor iOS they are rendered by a native SwiftUI `TabView` and toolbar; everywhere else the same controls appear as Web clones. -- Device-layout reporting — the bundled Capacitor plugin reports rail placement, hinge status and the WebView corner radius through `getDeviceLayout()` and the `deviceLayoutChange` event. -The pieces map to distinct responsibilities. The plugin reports **device facts** and never touches the DOM. The **application** decides what each value means for its layout. The **stylesheet and runtime** apply that decision — reserving space, projecting controls and adapting the split pane. Keeping these boundaries separate makes the native values easy to mock in tests and keeps the theme's own responsibility small. +Device state belongs to [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable). The **application** subscribes to its events and chooses its layout. This package's **stylesheet and runtime** apply that decision by reserving space, projecting controls and adapting the split pane. The theme does not monitor hinge state or bar placement. ## Choose what to adopt @@ -24,10 +23,10 @@ To keep your existing theme and add only the standalone support, follow [iPhone | Goal | Stylesheet | Runtime | | ------------------------------------------------ | ------------------- | ------------------------------------------------------------------ | -| Hinge posture only (layout switches) | none | none — subscribe to the plugin directly | -| Posture-driven split-pane width | `vertical-bars.css` | none — subscribe to the plugin directly | +| Hinge posture only (layout switches) | none | none — subscribe to `Foldable` directly | +| Posture-driven split-pane width | `vertical-bars.css` | none — subscribe to `Foldable` directly | | Vertical rail for tabs and toolbar actions | `vertical-bars.css` | `enableVerticalControlArea()` | -| Native shell plus the rail | `vertical-bars.css` | `enableNativeUIShell()` — already includes rail and posture support | +| Native shell plus the rail | `vertical-bars.css` | `enableNativeUIShell()` — includes rail projection | ```scss @use '@rdlabo/ionic-theme-ios27/dist/css/vertical-bars.css'; @@ -39,39 +38,39 @@ The `/vertical-bars` entry point imports `@capacitor/core` at module load, so in ## Read the device layout -`npx cap sync ios` registers the plugin automatically; no `configure` call is needed for device layout. An app that only wants the hinge posture — for example to drive a split pane — uses this API alone, with no projection runtime: +Install the device-state plugin in the application, then sync the native project: + +```bash +npm install @erkamyaman/capacitor-foldable@^8.3.1 +npx cap sync +``` + +Use Capacitor 8.5 or later and build with Xcode 27.1 or newer for iPhone Duo's iOS 27.1 APIs. The dependency is needed for device-driven layout, not for the theme's CSS, browser simulation, or native control projection alone. Do not import the plugin's `ionic-tabs.css` alongside this package's rail projection; both would reposition the same tabs. + +For a posture-driven split pane, subscribe directly without starting a projection runtime: ```ts -import { Capacitor } from '@capacitor/core'; -import { HingeStatus, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable'; -// The plugin has no Web implementation; guard the subscription. -if (Capacitor.getPlatform() === 'ios') { - await IonicNativeUIShell.startDeviceLayoutMonitoring(); - const listener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ hingeStatus }) => { - // apply the posture - }); - const { hingeStatus } = await IonicNativeUIShell.getDeviceLayout(); // initial value - - // When the consumer goes away: - // await listener.remove(); - // await IonicNativeUIShell.stopDeviceLayoutMonitoring(); -} -``` +const applyFold = (fold: FoldState) => { + const pane = document.querySelector('ion-split-pane'); + pane?.classList.toggle('ios-theme-split-pane-half-open', fold.state === 'half-opened'); + pane?.setAttribute('when', fold.hingeBounds ? '(min-width: 900px)' : '(min-width: 992px)'); +}; +const listener = await Foldable.addListener('foldStateChange', applyFold); +applyFold(await Foldable.getFoldState()); -`DeviceLayout` carries: +// When the consumer goes away: +// await listener.remove(); +``` -| Field | Meaning | -| ----------------------- | ---------------------------------------------------------------------------------------------------------- | -| `placement` | `{ edge: 'leading' \| 'trailing' \| null, inset }` — the rail's logical edge in the reading direction and its UIKit safe-area inset in points; `edge` is `null` on devices without a rail | -| `hingeStatus` | `HingeStatus.Closed`, `PartiallyOpen`, or `FullyOpen`; `null` when the device reports no hinge | -| `webViewMetrics.radius` | the WebView's effective top-left corner radius in points | +`getFoldState()` and `foldStateChange` report `state` (`'flat'`, `'half-opened'`, or `'closed'`), `posture`, and optional hinge geometry. Without fold information, the plugin returns a flat state without `hingeBounds`; restore the ordinary split-pane breakpoint in that case. The Web implementation also returns a flat state. -`edge` is a **logical** direction: `'leading'` is where a reader starts a line — the physical left in LTR and the physical right in RTL. This is the same vocabulary as UIKit's `verticalBarEdge` trait and `@erkamyaman/capacitor-foldable`'s `getBarPlacement()`, so values from that plugin can be applied as-is without conversion. +`getBarPlacement()` and `barPlacementChange` report `{ verticalBarEdge: 'leading' | 'trailing' | null }`. The edge is **logical**: leading is the physical left in LTR and the physical right in RTL. Pass `verticalBarEdge` to `setPlacement()`. No start/stop monitoring calls are needed; remove each listener when its owner is disposed. -Monitoring is reference-counted: each consumer pairs `startDeviceLayoutMonitoring()` with `stopDeviceLayoutMonitoring()`, and events stop when the last consumer releases it. `getDeviceLayout()` also works without monitoring for a one-shot read. While `enableVerticalControlArea()` or `enableNativeUIShell()` has native projection active it already holds a monitoring reference, so those users only add a listener and read the initial value — no extra start/stop pair. +The plugin does not report a safe-area inset with bar placement. The theme uses CSS safe-area values with its 80px rail fallback; an application can still pass `{ edge, inset }` to `setPlacement()` when it supplies an explicit width. WebView corner radius remains a rendering concern: `configureNativeTransition()` uses the shell's `getWebViewMetrics()` API, independently of `Foldable`. -**Build requirement:** iOS only enables the vertical bar for apps linked against the iOS 27.1 SDK or later — build with Xcode 27.1 or newer. Apps built with an older SDK run in backward-compatibility mode on iPhone Duo: the system reserves no rail, `placement.edge` stays `null`, `inset` stays `0`, and `hingeStatus` stays `null`. Everything else still works in that state — the opt-in classes reserve the DOM strip and the rail follows whatever placement the application applies — so the compat build remains usable and testable; only the real system rail, its measured inset and hinge posture require the newer toolchain. +**Migration:** the theme's former `DeviceLayout`, `HingeStatus`, `getDeviceLayout()`, `deviceLayoutChange`, and start/stop device-layout monitoring APIs have been removed. Replace device subscriptions with the `Foldable` APIs above; use `getWebViewMetrics()` for one-shot radius reads. Apps built without the iOS 27.1 SDK receive no real Duo bar placement or hinge data, but can still simulate a rail using a fixed edge. ## Reserve the vertical rail @@ -81,9 +80,9 @@ Add `.ios-theme-vertical-bars` to `ion-app` to reserve the rail region on the ph ... ``` -The classes are physical — `-left` always means the physical left edge — because CSS and the native renderer work in physical coordinates. `setPlacement` (below) is the usual way to apply them: it accepts the logical `placement.edge` reported by the plugin and resolves it through the document's direction, so an RTL app does not need its own conversion. +The classes are physical — `-left` always means the physical left edge — because CSS and the native renderer work in physical coordinates. `setPlacement` (below) is the usual way to apply them: it accepts the logical `verticalBarEdge` reported by `Foldable` and resolves it through the document's direction, so an RTL app does not need its own conversion. -For Chrome development, no native plugin is needed — the class alone reserves `80px` to simulate iPhone Duo. When `setPlacement` receives a native placement, the measured UIKit inset replaces the simulated width, even when that inset is less than `80px`. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout. +For Chrome development, no native plugin is needed — the class alone reserves `80px` to simulate iPhone Duo. When `setPlacement` receives an explicit `{ edge, inset }`, that inset replaces the fallback width, even when it is less than `80px`. Override `--ios-theme-vertical-bars-safe-area-left` or `--ios-theme-vertical-bars-safe-area-right` when simulating a different layout. This keeps routers and component backgrounds full-viewport. `ion-content` moves its scroll foreground, `ion-toolbar` moves its container foreground, and `ion-fab` adjusts only when 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. @@ -97,16 +96,16 @@ Start the standalone runtime once at application startup: ```ts import { Capacitor, type PluginListenerHandle } from '@capacitor/core'; -import { enableVerticalControlArea, IonicNativeUIShell } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { enableVerticalControlArea } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { Foldable } from '@erkamyaman/capacitor-foldable'; // Start on Chrome too; the Web projection stays idle until the class is present. const rail = await enableVerticalControlArea(); let layoutListener: PluginListenerHandle | undefined; if (Capacitor.getPlatform() === 'ios') { - // The runtime already monitors device layout; only subscribe. - layoutListener = await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) => rail.setPlacement(placement)); - rail.setPlacement((await IonicNativeUIShell.getDeviceLayout()).placement); + layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge }) => rail.setPlacement(verticalBarEdge)); + rail.setPlacement((await Foldable.getBarPlacement()).verticalBarEdge); } // Call when the application owner is disposed. @@ -118,11 +117,11 @@ const stopVerticalArea = async () => { `setPlacement` on the handle and the exported `setVerticalControlAreaPlacement` are the same function; either applies the application's chosen placement to the CSS layout and both projections. It requires a mounted `ion-app` — call it after the app root exists. -- Pass the `placement` object from `getDeviceLayout()`/`deviceLayoutChange`, or just a logical edge: `'leading'` or `'trailing'`. The logical edge resolves to a physical side through the nearest `dir` attribute, or through an explicit `rtl` second argument when the app already knows its direction. +- Pass `verticalBarEdge` from `getBarPlacement()`/`barPlacementChange`, a logical edge: `'leading'` or `'trailing'`. The logical edge resolves to a physical side through the nearest `dir` attribute, or through an explicit `rtl` second argument when the app already knows its direction. - Pass `null` to restore the ordinary layout. - The device-layout listener reports what iOS chose; the application decides whether to apply it. An app that wants a fixed edge regardless of the report can simply pass its own `'leading'` or `'trailing'`. -Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement(placement)` from its listener. +Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement(verticalBarEdge)` from its listener. On supported iOS versions the runtime hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar; on Web, Android, or when native projection is unavailable, Web clones remain the fallback. Back navigation can come from outside a fixed toolbar; other actions still require one. Fixed-toolbar actions need an icon or SVG, no direct text node, and standard `fill="default"` or `fill="clear"` — text-only actions stay in the original Web toolbar. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the horizontal toolbar. Placement is chosen when a routed page enters; changing an existing button's content does not move it between the toolbar and rail until the page leaves and re-enters. Menus, modals, and popovers retain their own toolbar layout. @@ -154,7 +153,7 @@ ion-split-pane { } ``` -The registered `--ios-theme-split-pane-width` defaults to `320px`; `.ios-theme-split-pane-half-open` sets it to `50vw`. Set `halfOpened` when `deviceLayoutChange` reports `HingeStatus.PartiallyOpen` (and read the initial value with `getDeviceLayout`). Ionic's `when` decides whether the menu is a persistent side pane; choose its breakpoint so the pane is hidden when closed — `null` means the device has no hinge, so restore the ordinary breakpoint for it. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout does not enable Vertical Bars or move an overlay menu. +The registered `--ios-theme-split-pane-width` defaults to `320px`; `.ios-theme-split-pane-half-open` sets it to `50vw`. Set `halfOpened` when `foldStateChange` reports `state === 'half-opened'` (and read the initial value with `getFoldState()`). Ionic's `when` decides whether the menu is a persistent side pane; choose its breakpoint so the pane is hidden when closed — absent `hingeBounds` means no fold geometry is available, so restore the ordinary breakpoint for it. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout does not enable Vertical Bars or move an overlay menu. ## Vertical Control Area API diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index d894a760..77988734 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -8,9 +8,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele public let jsName = "IonicNativeUIShell" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "getDeviceLayout", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "startDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "stopDeviceLayoutMonitoring", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise) ] @@ -27,16 +25,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele private var pendingTabExpiryWorks: [String: DispatchWorkItem] = [:] private var restoreTopEdge: (() -> Void)? private var observers: [NSObjectProtocol] = [] - private var lastVerticalBarEdge: String? - private var lastVerticalBarInset: CGFloat = 0 - private var verticalBarPlacementObserved = false - private weak var observedVerticalBarView: UIView? - private var unregisterVerticalBarObservation: (() -> Void)? - private weak var observedHingeView: UIView? - private var hingeInteraction: UIInteraction? - private var hingeStatus: String? - private var deviceLayoutMonitoring = 0 - private var lastDeviceLayout: String? + private var lastWebViewRadius: Double? public override func load() { for name in [UIApplication.didEnterBackgroundNotification, UIResponder.keyboardWillChangeFrameNotification, UIResponder.keyboardWillHideNotification, @@ -68,7 +57,6 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } } else if name == UIDevice.orientationDidChangeNotification { self.notifyWebViewMetricsChange() - self.notifyVerticalBarPlacementChange() } }) } @@ -78,8 +66,6 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if name == UIResponder.keyboardDidHideNotification { self?.keyboardVisible = false } self?.bridge?.triggerWindowJSEvent(eventName: "nativeUIShellRefresh", data: name == UIApplication.didBecomeActiveNotification ? "{\"retireSearch\":true}" : "{}") if name == UIApplication.didBecomeActiveNotification { self?.notifyWebViewMetricsChange() } - if name == UIApplication.didBecomeActiveNotification { self?.notifyVerticalBarPlacementChange() } - if name == UIApplication.didBecomeActiveNotification { self?.refreshHingeStatus() } }) } } @@ -88,17 +74,6 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele observers.forEach(NotificationCenter.default.removeObserver) } - private func stopDeviceLayoutObservation() { - unregisterVerticalBarObservation?() - unregisterVerticalBarObservation = nil - observedVerticalBarView = nil - if let interaction = hingeInteraction { - observedHingeView?.removeInteraction(interaction) - } - hingeInteraction = nil - observedHingeView = nil - } - private func webViewMetrics() -> JSObject? { guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil } webView.layoutIfNeeded() @@ -106,158 +81,21 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele } private func notifyWebViewMetricsChange() { - notifyDeviceLayoutChange() - } - - private func deviceLayout() -> JSObject { - var layout: JSObject = ["placement": verticalBarPlacement(), - "webViewMetrics": webViewMetrics() ?? ["radius": 0]] - layout["hingeStatus"] = hingeStatus ?? NSNull() - return layout - } - - private func notifyDeviceLayoutChange() { - guard deviceLayoutMonitoring > 0 else { return } - let layout = deviceLayout() - let fingerprint = "\(verticalBarEdge() ?? "none"):\(verticalBarInset(for: verticalBarEdge())):\(hingeStatus ?? "none"):\(webViewMetrics()?["radius"] ?? 0)" - guard fingerprint != lastDeviceLayout else { return } - lastDeviceLayout = fingerprint - notifyListeners("deviceLayoutChange", data: layout) - } - - @objc func getDeviceLayout(_ call: CAPPluginCall) { - DispatchQueue.main.async { [weak self] in - self?.observeVerticalBarPlacement() - self?.observeHingeStatus() - self?.refreshHingeStatus() - DispatchQueue.main.async { - call.resolve(self?.deviceLayout() ?? ["placement": ["edge": NSNull(), "inset": 0], "hingeStatus": NSNull(), "webViewMetrics": ["radius": 0]]) - if self?.deviceLayoutMonitoring == 0 { self?.stopDeviceLayoutObservation() } - } - } + let metrics = webViewMetrics() ?? ["radius": 0] + let radius = metrics["radius"] as? Double ?? 0 + guard radius != lastWebViewRadius else { return } + lastWebViewRadius = radius + notifyListeners("webViewMetricsChange", data: metrics) } - @objc func startDeviceLayoutMonitoring(_ call: CAPPluginCall) { + @objc func getWebViewMetrics(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in - self?.deviceLayoutMonitoring += 1 - self?.lastDeviceLayout = nil - self?.observeVerticalBarPlacement() - self?.observeHingeStatus() - self?.refreshHingeStatus() - call.resolve() - } - } - - @objc func stopDeviceLayoutMonitoring(_ call: CAPPluginCall) { - DispatchQueue.main.async { [weak self] in - if let self, self.deviceLayoutMonitoring > 0 { - self.deviceLayoutMonitoring -= 1 - if self.deviceLayoutMonitoring == 0 { - self.lastDeviceLayout = nil - self.stopDeviceLayoutObservation() - } - } - call.resolve() - } - } - - // Logical edge in the reading direction, matching UIVerticalBarEdge. - private func verticalBarEdge() -> String? { - #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) - if #available(iOS 27.1, *), let webView = bridge?.webView { - switch webView.traitCollection.verticalBarEdge { - case .leading: return "leading" - case .trailing: return "trailing" - default: break - } - } - #endif - // Toolchains older than the verticalBarEdge trait still expose the rail - // as a deep safe-area inset on the physical edge (iPhone Duo reserves - // ~80pt; ordinary iPhones stay below 70pt even in landscape). - guard let webView = bridge?.webView else { return nil } - webView.layoutIfNeeded() - let rtl = webView.effectiveUserInterfaceLayoutDirection == .rightToLeft - if webView.safeAreaInsets.right >= 70 { return rtl ? "leading" : "trailing" } - if webView.safeAreaInsets.left >= 70 { return rtl ? "trailing" : "leading" } - return nil - } - - private func verticalBarInset(for edge: String?) -> CGFloat { - guard let edge, let webView = bridge?.webView else { return 0 } - webView.layoutIfNeeded() - let rtl = webView.effectiveUserInterfaceLayoutDirection == .rightToLeft - let physicalRight = (edge == "trailing") != rtl - return physicalRight ? webView.safeAreaInsets.right : webView.safeAreaInsets.left - } - - private func verticalBarPlacement() -> JSObject { - if let edge = verticalBarEdge() { return ["edge": edge, "inset": Double(verticalBarInset(for: edge))] } - return ["edge": NSNull(), "inset": 0] - } - - private func notifyVerticalBarPlacementChange() { - let edge = verticalBarEdge() - let inset = verticalBarInset(for: edge) - guard !verticalBarPlacementObserved || edge != lastVerticalBarEdge || abs(inset - lastVerticalBarInset) > 0.5 else { return } - verticalBarPlacementObserved = true - lastVerticalBarEdge = edge - lastVerticalBarInset = inset - notifyDeviceLayoutChange() - } - - private func observeVerticalBarPlacement() { - #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) - if #available(iOS 27.1, *), let webView = bridge?.webView, observedVerticalBarView !== webView { - unregisterVerticalBarObservation?() - observedVerticalBarView = webView - let traits: [UITrait] = [UITraitLayoutDirection.self] + UITraitCollection.systemTraitsAffectingVerticalBarEdge - let registration = webView.registerForTraitChanges(traits) { [weak self] (_: UIView, _: UITraitCollection) in - self?.notifyVerticalBarPlacementChange() - } - unregisterVerticalBarObservation = { [weak webView] in webView?.unregisterForTraitChanges(registration) } - } - #endif - notifyVerticalBarPlacementChange() - } - - private func observeHingeStatus() { - #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) - if #available(iOS 27.1, *), let webView = bridge?.webView, observedHingeView !== webView { - if let interaction = hingeInteraction { - observedHingeView?.removeInteraction(interaction) - } - observedHingeView = webView - let interaction = UIHingeInteraction { [weak self] _, update in - let status: String? - switch update.hinge?.status { - case .closed: status = "closed" - case .partiallyOpen: status = "partiallyOpen" - case .fullyOpen: status = "fullyOpen" - default: status = nil - } - guard status != self?.hingeStatus else { return } - self?.hingeStatus = status - self?.notifyDeviceLayoutChange() - } - hingeInteraction = interaction - webView.addInteraction(interaction) - } - #endif - } - - private func refreshHingeStatus() { - #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) - if #available(iOS 27.1, *), let interaction = hingeInteraction as? UIHingeInteraction { - interaction.isEnabled = false - interaction.isEnabled = true + call.resolve(self?.webViewMetrics() ?? ["radius": 0]) } - #endif } @objc func configure(_ call: CAPPluginCall) { DispatchQueue.main.async { [weak self] in - self?.observeVerticalBarPlacement() // A new JS context starts revision numbering again (live reload / navigation). self?.restoreTopEdge?() self?.restoreTopEdge = nil @@ -352,6 +190,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele guard let snapshot = try? call.decode(ShellSnapshot.self), snapshot.isValid else { call.reject("Invalid control snapshot"); return } + self.notifyWebViewMetricsChange() let duration = ShellCrossfade.duration(snapshot.transitionDuration) let existing = Set(self.controls.keys) let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars } diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 3846d039..e6955c60 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -48,12 +48,6 @@ export interface VerticalBarPlacement { inset: number; } -export enum HingeStatus { - Closed = 'closed', - PartiallyOpen = 'partiallyOpen', - FullyOpen = 'fullyOpen', -} - export interface VerticalControlAreaHandle extends NativeUIShellHandle { /** Applies the application's chosen placement to both Web and native controls. */ setPlacement(placement: VerticalBarEdge | VerticalBarPlacement, rtl?: boolean): void; @@ -158,21 +152,12 @@ export interface WebViewMetrics { radius: number; } -export interface DeviceLayout { - placement: VerticalBarPlacement; - /** Fold hinge posture, or `null` when the device reports no hinge. */ - hingeStatus: HingeStatus | null; - webViewMetrics: WebViewMetrics; -} - export interface NativeUIShellPlugin { configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; - getDeviceLayout(): Promise; - startDeviceLayoutMonitoring(): Promise; - stopDeviceLayoutMonitoring(): Promise; + getWebViewMetrics(): Promise; update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>; clear(options: { revision: number }): Promise; addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise; addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise; - addListener(name: 'deviceLayoutChange', listener: (event: DeviceLayout) => void): Promise; + addListener(name: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise; } diff --git a/src/native/index.ts b/src/native/index.ts index 92d3bf6b..b60bde23 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -19,13 +19,11 @@ export type { NativeUIShellOptions, NativeUIShellStatus, NativeUIShellSuspension, - DeviceLayout, VerticalBarEdge, VerticalBarPlacement, VerticalControlAreaHandle, WebViewMetrics, } from './definitions'; -export { HingeStatus } from './definitions'; const plugin = registerPlugin('IonicNativeUIShell'); export const IonicNativeUIShell = plugin; @@ -97,8 +95,7 @@ const manage = ( /** Reads the current native WebView geometry and applies it to page transitions. */ export const configureNativeTransition = async (): Promise => { - const metrics = - typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? (await plugin.getDeviceLayout()).webViewMetrics : { radius: 0 }; + const metrics = typeof document !== 'undefined' && Capacitor.getPlatform() === 'ios' ? await plugin.getWebViewMetrics() : { radius: 0 }; setConfig({ radius: metrics.radius }); return metrics; }; @@ -181,31 +178,18 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise (async () => { if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS'); let runtime: NativeUIShellHandle | undefined; - let placementListener: Awaited> | undefined; - let monitoring = false; + let metricsListener: Awaited> | undefined; try { - if (!options.verticalBarsOnly) await configureNativeTransition().catch(() => undefined); const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true }); if (!capabilities.supported) return fallback('Requires iOS 26 or later'); - let nativeEdge: VerticalBarEdge = null; - const nativeVerticalBars = () => { - const root = document.querySelector('ion-app.ios-theme-vertical-bars'); - if (!root) return false; - // The trait stays unspecified when the OS cannot report a rail — for - // example an app linked against an SDK older than 27.1 — so the DOM - // class is trusted there. When the OS does report an edge, the native - // rail only takes over once the app has applied the matching class. - const domEdge = root.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right'; - return nativeEdge === null || physicalVerticalBarEdge(nativeEdge, elementRtl(root)) === domEdge; - }; - await plugin.startDeviceLayoutMonitoring(); - monitoring = true; - placementListener = await plugin.addListener('deviceLayoutChange', ({ placement, webViewMetrics }) => { - nativeEdge = placement.edge; - if (!options.verticalBarsOnly) setConfig({ radius: webViewMetrics.radius }); - document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh')); - }); - nativeEdge = (await plugin.getDeviceLayout()).placement.edge; + // The application owns device state and selects the rail through placement classes. + const nativeVerticalBars = () => !!document.querySelector('ion-app.ios-theme-vertical-bars'); + if (!options.verticalBarsOnly) { + metricsListener = await plugin.addListener('webViewMetricsChange', (metrics) => { + setConfig({ radius: metrics.radius }); + }); + await configureNativeTransition().catch(() => undefined); + } const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true); runtime = combine( native, @@ -215,15 +199,13 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise suspend: () => prehide?.suspend(), release, destroy: async () => { - await placementListener?.remove().catch(() => {}); - if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {}); + await metricsListener?.remove().catch(() => {}); prehide?.stop(); }, }); } catch (error) { await runtime?.destroy(); - await placementListener?.remove().catch(() => {}); - if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {}); + await metricsListener?.remove().catch(() => {}); return fallback(error instanceof Error ? error.message : String(error)); } })()); diff --git a/src/vertical-bars.ts b/src/vertical-bars.ts index 2771a0b0..c5855fb4 100644 --- a/src/vertical-bars.ts +++ b/src/vertical-bars.ts @@ -1,4 +1,3 @@ export { enableVerticalControlArea, setVerticalControlAreaPlacement, IonicNativeUIShell } from './native'; -export { HingeStatus } from './native'; export { withNativeUIShellTransition } from './native-integration/transition'; -export type { DeviceLayout, NativeUIShellStatus, NativeUIShellSuspension, VerticalBarEdge, VerticalControlAreaHandle } from './native'; +export type { NativeUIShellStatus, NativeUIShellSuspension, VerticalBarEdge, VerticalControlAreaHandle } from './native'; From 39bcb6c1196611da926ff868125cdf3089d7d245 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 27 Sep 2026 10:20:08 +0900 Subject: [PATCH 02/12] fix: preserve rail fallback when native placement differs --- demo/e2e/native-ui-shell.spec.ts | 44 ++++++++++++++++--- docs/iphone-duo.md | 2 +- .../Components/ShellVerticalBars.swift | 19 ++++++++ .../IonicNativeUIShellPlugin.swift | 10 +++-- src/native/definitions.ts | 8 +++- src/native/index.ts | 16 ++++++- src/native/runtime.ts | 2 + 7 files changed, 86 insertions(+), 15 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index efc7fd3c..3a6bc1b9 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -9,6 +9,7 @@ import type { ShellMockCore, TestAppElement } from './native-shell-mock'; const importer = new NodePackageImporter(resolve(__dirname, '../../')); interface ShellMock extends ShellMockCore { + nativeEdge: 'leading' | 'trailing' | null; delay: number; hang: boolean; rejectInactiveSearch: boolean; @@ -25,6 +26,7 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra const mock = { updates: [] as ShellSnapshot[], sequence: 0, + nativeEdge, delay: 0, hang: false, rejectInactiveSearch: false, @@ -56,7 +58,13 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra async update(options: ShellSnapshot) { this.updates.push(options); if (this.hang) await new Promise(() => {}); - return this.rejections(options); + const root = document.querySelector('ion-app'); + const rtl = root?.closest('[dir]')?.getAttribute('dir') === 'rtl'; + const physicalEdge = (this.nativeEdge === 'leading') !== rtl ? 'left' : 'right'; + return { + ...(await this.rejections(options)), + verticalBarsSupported: !options.verticalBarEdge || this.nativeEdge === null || options.verticalBarEdge === physicalEdge, + }; }, async clear(options: { revision: number }) { this.updates.push({ revision: options.revision, viewportWidth: 0, controls: [] }); @@ -799,10 +807,10 @@ test('verticalBars rail remains native while its Ionic menu is open', async ({ p await expect(morphedCancel).toBeVisible(); }); -// If native projection fails, the Web fallback still owns the application-selected rail. -test('verticalBars controls stay operable on Web when native projection fails', async ({ page }) => { +// 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, true, 'leading'); + await mockNative(page, false, 'leading'); 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')); @@ -826,9 +834,9 @@ test('verticalBars controls stay operable on Web when native projection fails', await expect .poll(() => page.evaluate(() => - Capacitor.registerPlugin('IonicNativeUIShell').updates.every((snapshot: ShellSnapshot) => - snapshot.controls.every((control: ShellControl) => control.placement !== 'vertical-bars'), - ), + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.at(-1) + ?.controls.every((control: ShellControl) => control.placement !== 'vertical-bars'), ), ) .toBe(true); @@ -2786,3 +2794,25 @@ test('demo applies Foldable placement and fold events while keeping the rail tog ); await expect(app).not.toHaveClass(/ios-theme-vertical-bars/); }); + +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('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('IonicNativeUIShell').nativeEdge = 'trailing'; + window.dispatchEvent(new Event('nativeUIShellRefresh')); + }); + 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', ''); +}); diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md index 853e4410..7e8b09cd 100644 --- a/docs/iphone-duo.md +++ b/docs/iphone-duo.md @@ -119,7 +119,7 @@ const stopVerticalArea = async () => { - Pass `verticalBarEdge` from `getBarPlacement()`/`barPlacementChange`, a logical edge: `'leading'` or `'trailing'`. The logical edge resolves to a physical side through the nearest `dir` attribute, or through an explicit `rtl` second argument when the app already knows its direction. - Pass `null` to restore the ordinary layout. -- The device-layout listener reports what iOS chose; the application decides whether to apply it. An app that wants a fixed edge regardless of the report can simply pass its own `'leading'` or `'trailing'`. +- The device-layout listener reports what iOS chose; the application decides whether to apply it. The native renderer checks whether it can draw on the requested edge; a mismatch uses the Web rail until the edges match again. An app that wants a fixed edge regardless of the report can simply pass its own `'leading'` or `'trailing'`. Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement(verticalBarEdge)` from its listener. diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index 79522e51..a2c8e0b5 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -340,6 +340,25 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { controller.didMove(toParent: owner) } + // A rendering capability check, not device-state reporting or observation. + // SwiftUI chooses its rail from UIKit traits; edge only selects our hit region. + static func supports(edge: String?, in view: UIView) -> Bool { + guard let edge else { return true } + #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) + if #available(iOS 27.1, *) { + let leading: Bool + switch view.traitCollection.verticalBarEdge { + case .leading: leading = true + case .trailing: leading = false + default: return true + } + let rtl = view.effectiveUserInterfaceLayoutDirection == .rightToLeft + return edge == (leading != rtl ? "left" : "right") + } + #endif + return true + } + func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) { container.railEdge = edge controller.railEdge = edge diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 77988734..fd0382f6 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -193,13 +193,15 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele self.notifyWebViewMetricsChange() let duration = ShellCrossfade.duration(snapshot.transitionDuration) let existing = Set(self.controls.keys) - let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars } + let verticalBarsSupported = ShellVerticalBarsController.supports( + edge: snapshot.verticalBarEdge, in: self.bridge?.viewController?.view ?? webView) + let verticalBars = verticalBarsSupported ? snapshot.controls.filter { $0.placement == .verticalBars } : [] let snapshots = snapshot.controls.filter { $0.placement != .verticalBars } let width = snapshot.viewportWidth self.revision = next if snapshots.isEmpty && verticalBars.isEmpty { self.removeControls(duration: duration) - call.resolve(["revision": next]); return + call.resolve(["revision": next, "verticalBarsSupported": verticalBarsSupported]); return } let scale = webView.bounds.width / width let retained = Set(snapshots.map(\.id)) @@ -226,7 +228,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if snapshots.isEmpty { self.host?.removeFromSuperview() self.host = nil - call.resolve(["revision": next, "rejectedControls": rejectedControls]) + call.resolve(["revision": next, "rejectedControls": rejectedControls, "verticalBarsSupported": verticalBarsSupported]) return } let host = self.host ?? ShellHost() @@ -356,7 +358,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele for (id, control) in self.controls where !existing.contains(id) && self.searchControllers[id] == nil { ShellCrossfade.enter(control, duration: duration) } - let complete = { call.resolve(["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls]) } + let complete = { call.resolve(["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls, "verticalBarsSupported": verticalBarsSupported]) } if let coordinator = searches.first?.0.transitionCoordinator, coordinator.animate(alongsideTransition: nil, completion: { _ in complete() }) { return } complete() diff --git a/src/native/definitions.ts b/src/native/definitions.ts index e6955c60..574a0b59 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -155,7 +155,13 @@ export interface WebViewMetrics { export interface NativeUIShellPlugin { configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; getWebViewMetrics(): Promise; - update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>; + update(snapshot: ShellSnapshot): Promise<{ + revision: number; + rejectedSearches?: string[]; + rejectedControls?: string[]; + /** Whether the renderer can honor this snapshot's requested rail edge. */ + verticalBarsSupported?: boolean; + }>; clear(options: { revision: number }): Promise; addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise; addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise; diff --git a/src/native/index.ts b/src/native/index.ts index b60bde23..a183f871 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -183,14 +183,26 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true }); if (!capabilities.supported) return fallback('Requires iOS 26 or later'); // The application owns device state and selects the rail through placement classes. - const nativeVerticalBars = () => !!document.querySelector('ion-app.ios-theme-vertical-bars'); + let verticalBarsSupported = true; + const nativeVerticalBars = () => verticalBarsSupported && !!document.querySelector('ion-app.ios-theme-vertical-bars'); if (!options.verticalBarsOnly) { metricsListener = await plugin.addListener('webViewMetricsChange', (metrics) => { setConfig({ radius: metrics.radius }); }); await configureNativeTransition().catch(() => undefined); } - const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true); + const native = await createRuntime( + document, + plugin, + options, + nativeVerticalBars, + options.verticalBarsOnly === true, + (supported) => { + if (verticalBarsSupported === supported) return; + verticalBarsSupported = supported; + document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh')); + }, + ); runtime = combine( native, createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped'), diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 528c0e43..100901c8 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -44,6 +44,7 @@ export const createRuntime = async ( options: NativeUIShellOptions = {}, nativeVerticalBars: () => boolean = () => true, verticalBarsOnly = false, + onVerticalBarsSupport?: (supported: boolean) => void, ): Promise => { const win = doc.defaultView!; const icons = createIconRenderer(); @@ -344,6 +345,7 @@ export const createRuntime = async ( const result = await bounded(plugin.update(snapshot)); if (stopped) return; if (result.revision !== snapshot.revision) throw new Error('Native UI Shell revision mismatch'); + onVerticalBarsSupport?.(result.verticalBarsSupported !== false); if (`${win.innerWidth}:${win.innerHeight}` !== size) { lastSnapshot = ''; dirty = true; From 82d335e1f6e84c986d5de8de4e660f253d97cf9c Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 27 Sep 2026 10:26:02 +0900 Subject: [PATCH 03/12] test: inject Foldable for reliable isolated Angular tests --- demo/src/app/tabs/tabs.page.spec.ts | 27 ++++++++++++++------------- demo/src/app/tabs/tabs.page.ts | 12 ++++++++---- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/demo/src/app/tabs/tabs.page.spec.ts b/demo/src/app/tabs/tabs.page.spec.ts index 9489a188..54ffcc1b 100644 --- a/demo/src/app/tabs/tabs.page.spec.ts +++ b/demo/src/app/tabs/tabs.page.spec.ts @@ -1,24 +1,25 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Capacitor } from '@capacitor/core'; -import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable'; -import { vi } from 'vitest'; +import { Capacitor, type PluginListenerHandle } from '@capacitor/core'; +import type { FoldState } from '@erkamyaman/capacitor-foldable'; +import { vi, type Mock } from 'vitest'; -vi.mock('@erkamyaman/capacitor-foldable', () => ({ - Foldable: { addListener: vi.fn(), getFoldState: vi.fn() }, -})); - -import { TabsPage } from './tabs.page'; +import { FOLDABLE, TabsPage } from './tabs.page'; import { testConfig } from '../../../util/test.config'; describe('TabsPage', () => { let component: TabsPage; let fixture: ComponentFixture; + let foldable: { + addListener: Mock<(event: 'foldStateChange', listener: (fold: FoldState) => void) => Promise>; + getFoldState: Mock<() => Promise>; + }; beforeEach(async () => { + foldable = { addListener: vi.fn(), getFoldState: vi.fn() }; await TestBed.configureTestingModule({ imports: [TabsPage], - providers: testConfig.providers, + providers: [...testConfig.providers, { provide: FOLDABLE, useValue: foldable }], }).compileComponents(); }); @@ -38,11 +39,11 @@ describe('TabsPage', () => { vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); const remove = vi.fn().mockResolvedValue(undefined); let emit!: (fold: FoldState) => void; - vi.mocked(Foldable.addListener).mockImplementation(async (_event, listener) => { - emit = listener as unknown as (fold: FoldState) => void; + foldable.addListener.mockImplementation(async (_event, listener) => { + emit = listener; return { remove }; }); - vi.mocked(Foldable.getFoldState).mockResolvedValue({ + foldable.getFoldState.mockResolvedValue({ state: 'half-opened', posture: 'book', isSeparating: true, @@ -63,7 +64,7 @@ describe('TabsPage', () => { vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); const remove = vi.fn().mockResolvedValue(undefined); let registered!: (handle: { remove: typeof remove }) => void; - vi.mocked(Foldable.addListener).mockReturnValue( + foldable.addListener.mockReturnValue( new Promise((resolve) => { registered = resolve; }), diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts index a4cbe6b5..e191b245 100644 --- a/demo/src/app/tabs/tabs.page.ts +++ b/demo/src/app/tabs/tabs.page.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, ElementRef, inject, OnDestroy, OnInit, viewChild } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, inject, InjectionToken, OnDestroy, OnInit, viewChild } from '@angular/core'; import { IonContent, IonIcon, @@ -19,9 +19,12 @@ import { filter } from 'rxjs'; // import { registerTabBarEffect } from '@rdlabo/ionic-theme-ios27'; import { registeredEffect, registerTabBarEffect } from '../../../../src'; -import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable'; +import { Foldable, type FoldablePlugin, type FoldState } from '@erkamyaman/capacitor-foldable'; import { Capacitor } from '@capacitor/core'; +// Inject the plugin so Angular tests can replace the bridge without module mocking. +export const FOLDABLE = new InjectionToken('Foldable', { providedIn: 'root', factory: () => Foldable }); + @Component({ selector: 'app-tabs', templateUrl: 'tabs.page.html', @@ -43,6 +46,7 @@ import { Capacitor } from '@capacitor/core'; }) export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, ViewDidLeave { readonly #router = inject(Router); + readonly #foldable = inject(FOLDABLE); readonly #el = inject(ElementRef); readonly splitPane = viewChild.required>('splitPane', { read: ElementRef }); #hingeListener?: { remove(): Promise }; @@ -75,11 +79,11 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, async observeHinge() { if (Capacitor.getPlatform() !== 'ios') return; - this.#hingeListener = await Foldable.addListener('foldStateChange', (fold) => { + this.#hingeListener = await this.#foldable.addListener('foldStateChange', (fold) => { if (!this.#destroyed) this.setFoldState(fold); }); if (this.#destroyed) return this.#releaseHinge(); - const fold = await Foldable.getFoldState(); + const fold = await this.#foldable.getFoldState(); if (!this.#destroyed) this.setFoldState(fold); } From 15c8e17607dfb8e36c8d9ce83719ba2d03f64c4c Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 27 Sep 2026 10:33:40 +0900 Subject: [PATCH 04/12] test: remove temporary Foldable wiring checks --- demo/e2e/native-ui-shell.spec.ts | 28 --------------- demo/src/app/tabs/tabs.page.spec.ts | 56 ++--------------------------- demo/src/app/tabs/tabs.page.ts | 12 +++---- 3 files changed, 6 insertions(+), 90 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 3a6bc1b9..e91d692f 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -2767,34 +2767,6 @@ test('rejected search retries when tab content changes without resizing', async expect(await page.locator('ion-tab-bar').boundingBox()).toEqual(before); }); -test('demo applies Foldable placement and fold events while keeping the rail toggle app-owned', async ({ page }) => { - await mockNative(page, false, 'leading'); - await page.goto('/main/index'); - const app = page.locator('ion-app'); - const toggle = page.getByRole('switch', { name: 'iPhone Duo Mode' }); - await toggle.click(); - await expect(app).toHaveClass(/ios-theme-vertical-bars-left/); - await page.evaluate(() => { - const foldable = Capacitor.registerPlugin<{ notifyListeners(name: string, value: unknown): void }>('Foldable'); - foldable.notifyListeners('barPlacementChange', { verticalBarEdge: 'trailing' }); - foldable.notifyListeners('foldStateChange', { - state: 'half-opened', - posture: 'book', - isSeparating: true, - hingeBounds: { x: 450, y: 0, width: 0, height: 900 }, - }); - }); - await expect(app).not.toHaveClass(/ios-theme-vertical-bars-left/); - await expect(page.locator('ion-split-pane')).toHaveClass(/ios-theme-split-pane-half-open/); - await toggle.click(); - await page.evaluate(() => - Capacitor.registerPlugin<{ notifyListeners(name: string, value: unknown): void }>('Foldable').notifyListeners('barPlacementChange', { - verticalBarEdge: 'leading', - }), - ); - await expect(app).not.toHaveClass(/ios-theme-vertical-bars/); -}); - 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'); diff --git a/demo/src/app/tabs/tabs.page.spec.ts b/demo/src/app/tabs/tabs.page.spec.ts index 54ffcc1b..0ce47adc 100644 --- a/demo/src/app/tabs/tabs.page.spec.ts +++ b/demo/src/app/tabs/tabs.page.spec.ts @@ -1,25 +1,16 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Capacitor, type PluginListenerHandle } from '@capacitor/core'; -import type { FoldState } from '@erkamyaman/capacitor-foldable'; -import { vi, type Mock } from 'vitest'; - -import { FOLDABLE, TabsPage } from './tabs.page'; +import { TabsPage } from './tabs.page'; import { testConfig } from '../../../util/test.config'; describe('TabsPage', () => { let component: TabsPage; let fixture: ComponentFixture; - let foldable: { - addListener: Mock<(event: 'foldStateChange', listener: (fold: FoldState) => void) => Promise>; - getFoldState: Mock<() => Promise>; - }; beforeEach(async () => { - foldable = { addListener: vi.fn(), getFoldState: vi.fn() }; await TestBed.configureTestingModule({ imports: [TabsPage], - providers: [...testConfig.providers, { provide: FOLDABLE, useValue: foldable }], + providers: testConfig.providers, }).compileComponents(); }); @@ -29,50 +20,7 @@ describe('TabsPage', () => { fixture.detectChanges(); }); - afterEach(() => vi.restoreAllMocks()); - it('should create', () => { expect(component).toBeTruthy(); }); - - it('applies the initial fold, updates the pane, and removes its listener on destroy', async () => { - vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); - const remove = vi.fn().mockResolvedValue(undefined); - let emit!: (fold: FoldState) => void; - foldable.addListener.mockImplementation(async (_event, listener) => { - emit = listener; - return { remove }; - }); - foldable.getFoldState.mockResolvedValue({ - state: 'half-opened', - posture: 'book', - isSeparating: true, - hingeBounds: { x: 450, y: 0, width: 0, height: 900 }, - }); - await component.observeHinge(); - const pane = fixture.nativeElement.querySelector('ion-split-pane'); - expect(pane.classList.contains('ios-theme-split-pane-half-open')).toBe(true); - expect(pane.getAttribute('when')).toBe('(min-width: 900px)'); - emit({ state: 'flat', posture: 'flat', isSeparating: false }); - expect(pane.classList.contains('ios-theme-split-pane-half-open')).toBe(false); - expect(pane.getAttribute('when')).toBe('(min-width: 992px)'); - fixture.destroy(); - expect(remove).toHaveBeenCalledTimes(1); - }); - - it('removes a listener whose registration completes after destruction', async () => { - vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); - const remove = vi.fn().mockResolvedValue(undefined); - let registered!: (handle: { remove: typeof remove }) => void; - foldable.addListener.mockReturnValue( - new Promise((resolve) => { - registered = resolve; - }), - ); - const observing = component.observeHinge(); - fixture.destroy(); - registered({ remove }); - await observing; - expect(remove).toHaveBeenCalledTimes(1); - }); }); diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts index e191b245..a4cbe6b5 100644 --- a/demo/src/app/tabs/tabs.page.ts +++ b/demo/src/app/tabs/tabs.page.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, ElementRef, inject, InjectionToken, OnDestroy, OnInit, viewChild } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, inject, OnDestroy, OnInit, viewChild } from '@angular/core'; import { IonContent, IonIcon, @@ -19,12 +19,9 @@ import { filter } from 'rxjs'; // import { registerTabBarEffect } from '@rdlabo/ionic-theme-ios27'; import { registeredEffect, registerTabBarEffect } from '../../../../src'; -import { Foldable, type FoldablePlugin, type FoldState } from '@erkamyaman/capacitor-foldable'; +import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable'; import { Capacitor } from '@capacitor/core'; -// Inject the plugin so Angular tests can replace the bridge without module mocking. -export const FOLDABLE = new InjectionToken('Foldable', { providedIn: 'root', factory: () => Foldable }); - @Component({ selector: 'app-tabs', templateUrl: 'tabs.page.html', @@ -46,7 +43,6 @@ export const FOLDABLE = new InjectionToken('Foldable', { provide }) export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, ViewDidLeave { readonly #router = inject(Router); - readonly #foldable = inject(FOLDABLE); readonly #el = inject(ElementRef); readonly splitPane = viewChild.required>('splitPane', { read: ElementRef }); #hingeListener?: { remove(): Promise }; @@ -79,11 +75,11 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, async observeHinge() { if (Capacitor.getPlatform() !== 'ios') return; - this.#hingeListener = await this.#foldable.addListener('foldStateChange', (fold) => { + this.#hingeListener = await Foldable.addListener('foldStateChange', (fold) => { if (!this.#destroyed) this.setFoldState(fold); }); if (this.#destroyed) return this.#releaseHinge(); - const fold = await this.#foldable.getFoldState(); + const fold = await Foldable.getFoldState(); if (!this.#destroyed) this.setFoldState(fold); } From 2d8aace8cf42856bebdf57cfeee3d2d081f9e6ba Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 27 Sep 2026 10:46:27 +0900 Subject: [PATCH 05/12] refactor: accept native rail placement from the application --- demo/e2e/native-ui-shell.spec.ts | 15 +++------ demo/src/app/index/index-page.component.ts | 4 +-- demo/src/main.ts | 13 +++++--- docs/iphone-duo-with-original-theme.md | 5 +-- docs/iphone-duo.md | 26 +++++++++------ .../Components/ShellVerticalBars.swift | 19 ----------- .../IonicNativeUIShellPlugin.swift | 10 +++--- src/native/definitions.ts | 14 +++----- src/native/index.ts | 32 ++++++++++--------- src/native/runtime.ts | 2 -- 10 files changed, 59 insertions(+), 81 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index e91d692f..624b1b41 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -9,7 +9,6 @@ import type { ShellMockCore, TestAppElement } from './native-shell-mock'; const importer = new NodePackageImporter(resolve(__dirname, '../../')); interface ShellMock extends ShellMockCore { - nativeEdge: 'leading' | 'trailing' | null; delay: number; hang: boolean; rejectInactiveSearch: boolean; @@ -26,7 +25,6 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra const mock = { updates: [] as ShellSnapshot[], sequence: 0, - nativeEdge, delay: 0, hang: false, rejectInactiveSearch: false, @@ -58,13 +56,7 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra async update(options: ShellSnapshot) { this.updates.push(options); if (this.hang) await new Promise(() => {}); - const root = document.querySelector('ion-app'); - const rtl = root?.closest('[dir]')?.getAttribute('dir') === 'rtl'; - const physicalEdge = (this.nativeEdge === 'leading') !== rtl ? 'left' : 'right'; - return { - ...(await this.rejections(options)), - verticalBarsSupported: !options.verticalBarEdge || this.nativeEdge === null || options.verticalBarEdge === physicalEdge, - }; + return this.rejections(options); }, async clear(options: { revision: number }) { this.updates.push({ revision: options.revision, viewportWidth: 0, controls: [] }); @@ -2776,8 +2768,9 @@ test('verticalBars return to native projection when the requested edge matches a await expect(clone).toBeVisible(); const rotate = () => page.evaluate(() => { - Capacitor.registerPlugin('IonicNativeUIShell').nativeEdge = 'trailing'; - window.dispatchEvent(new Event('nativeUIShellRefresh')); + Capacitor.registerPlugin<{ notifyListeners(name: string, value: unknown): void }>('Foldable').notifyListeners('barPlacementChange', { + verticalBarEdge: 'trailing', + }); }); await rotate(); await expect(clone).toHaveCount(0); diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index 2f4c32a4..94d04cc0 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -19,7 +19,7 @@ import { ToggleCustomEvent, } from '@demo/ionic'; import { ActivatedRoute, Router } from '@angular/router'; -import { setVerticalControlAreaPlacement } from '@rdlabo/ionic-theme-ios27/vertical-bars'; +import { setVerticalControlAreaPlacement } from '../../../../src/vertical-bars'; import { Foldable } from '@erkamyaman/capacitor-foldable'; interface IComponent { @@ -105,6 +105,6 @@ export class IndexPageComponent { async changeVerticalBarsMode(event: ToggleCustomEvent) { if (!event.detail.checked) return setVerticalControlAreaPlacement(null); const { verticalBarEdge } = await Foldable.getBarPlacement(); - setVerticalControlAreaPlacement(verticalBarEdge ?? 'trailing'); + setVerticalControlAreaPlacement({ edge: verticalBarEdge ?? 'trailing', nativeEdge: verticalBarEdge }); } } diff --git a/demo/src/main.ts b/demo/src/main.ts index 578f9f67..773c9343 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -4,7 +4,7 @@ import { AppComponent } from './app/app.component'; import { enableNativeUIShell } from '../../src/native'; import { enableVerticalControlArea, setVerticalControlAreaPlacement } from '../../src/vertical-bars'; import { Capacitor } from '@capacitor/core'; -import { Foldable } from '@erkamyaman/capacitor-foldable'; +import { Foldable, type BarPlacement } from '@erkamyaman/capacitor-foldable'; import { iosTransitionAnimation, popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios27'; /** @@ -26,13 +26,16 @@ function loadIOSAnimations(): IonicAnimationOptions { void bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())) .then(async () => { if (Capacitor.getPlatform() !== 'ios') return; - await Foldable.addListener('barPlacementChange', ({ verticalBarEdge }) => { - const app = document.querySelector('ion-app.ios-theme-vertical-bars'); + const applyPlacement = ({ verticalBarEdge }: BarPlacement) => { + const app = document.querySelector('ion-app'); if (!app) return; + const enabled = app.classList.contains('ios-theme-vertical-bars'); const rtl = app.closest('[dir]')?.getAttribute('dir') === 'rtl'; const current = app.classList.contains('ios-theme-vertical-bars-left') !== rtl ? 'leading' : 'trailing'; - setVerticalControlAreaPlacement(verticalBarEdge ?? current); - }); + setVerticalControlAreaPlacement({ edge: enabled ? (verticalBarEdge ?? current) : null, nativeEdge: verticalBarEdge }); + }; + await Foldable.addListener('barPlacementChange', applyPlacement); + applyPlacement(await Foldable.getBarPlacement()); }) .catch((err) => console.error(err)); const startShell = new URLSearchParams(window.location.search).has('verticalBarsOnly') ? enableVerticalControlArea : enableNativeUIShell; diff --git a/docs/iphone-duo-with-original-theme.md b/docs/iphone-duo-with-original-theme.md index 87b1c708..c6d9354c 100644 --- a/docs/iphone-duo-with-original-theme.md +++ b/docs/iphone-duo-with-original-theme.md @@ -143,9 +143,10 @@ let layoutListener: PluginListenerHandle | undefined; if (Capacitor.getPlatform() === 'ios') { layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge }) => - rail.setPlacement(verticalBarEdge), + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge }), ); - rail.setPlacement((await Foldable.getBarPlacement()).verticalBarEdge); + const { verticalBarEdge } = await Foldable.getBarPlacement(); + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge }); } // Call when the application owner is disposed. diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md index 7e8b09cd..73f4a29d 100644 --- a/docs/iphone-duo.md +++ b/docs/iphone-duo.md @@ -66,7 +66,9 @@ applyFold(await Foldable.getFoldState()); `getFoldState()` and `foldStateChange` report `state` (`'flat'`, `'half-opened'`, or `'closed'`), `posture`, and optional hinge geometry. Without fold information, the plugin returns a flat state without `hingeBounds`; restore the ordinary split-pane breakpoint in that case. The Web implementation also returns a flat state. -`getBarPlacement()` and `barPlacementChange` report `{ verticalBarEdge: 'leading' | 'trailing' | null }`. The edge is **logical**: leading is the physical left in LTR and the physical right in RTL. Pass `verticalBarEdge` to `setPlacement()`. No start/stop monitoring calls are needed; remove each listener when its owner is disposed. +`getBarPlacement()` and `barPlacementChange` report `{ verticalBarEdge: 'leading' | 'trailing' | null }`. The edge is **logical**: leading is the physical left in LTR and the physical right in RTL. Pass `{ edge: verticalBarEdge, nativeEdge: verticalBarEdge }` to `setPlacement()`. No start/stop monitoring calls are needed; remove each listener when its owner is disposed. + +The theme never reads UIKit bar-placement traits. The application supplies `nativeEdge` on the initial read and each event, even when choosing a fixed `edge`. Omit `nativeEdge` to keep the last supplied value; pass `null` when the plugin reports no edge. Without a reported edge, the runtime follows the application's classes for previews and older SDKs. Passing `{ edge: null, nativeEdge }` updates the reported edge while keeping the rail disabled. The plugin does not report a safe-area inset with bar placement. The theme uses CSS safe-area values with its 80px rail fallback; an application can still pass `{ edge, inset }` to `setPlacement()` when it supplies an explicit width. WebView corner radius remains a rendering concern: `configureNativeTransition()` uses the shell's `getWebViewMetrics()` API, independently of `Foldable`. @@ -104,8 +106,11 @@ const rail = await enableVerticalControlArea(); let layoutListener: PluginListenerHandle | undefined; if (Capacitor.getPlatform() === 'ios') { - layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge }) => rail.setPlacement(verticalBarEdge)); - rail.setPlacement((await Foldable.getBarPlacement()).verticalBarEdge); + layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge }) => + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge }), + ); + const { verticalBarEdge } = await Foldable.getBarPlacement(); + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge }); } // Call when the application owner is disposed. @@ -117,11 +122,11 @@ const stopVerticalArea = async () => { `setPlacement` on the handle and the exported `setVerticalControlAreaPlacement` are the same function; either applies the application's chosen placement to the CSS layout and both projections. It requires a mounted `ion-app` — call it after the app root exists. -- Pass `verticalBarEdge` from `getBarPlacement()`/`barPlacementChange`, a logical edge: `'leading'` or `'trailing'`. The logical edge resolves to a physical side through the nearest `dir` attribute, or through an explicit `rtl` second argument when the app already knows its direction. +- Pass `{ edge, nativeEdge }`: `edge` is the application's chosen logical edge; `nativeEdge` is `verticalBarEdge` from `Foldable.getBarPlacement()`/`barPlacementChange`. They resolve through the nearest `dir` attribute, or the explicit `rtl` argument. - Pass `null` to restore the ordinary layout. -- The device-layout listener reports what iOS chose; the application decides whether to apply it. The native renderer checks whether it can draw on the requested edge; a mismatch uses the Web rail until the edges match again. An app that wants a fixed edge regardless of the report can simply pass its own `'leading'` or `'trailing'`. +- The device-layout listener reports what iOS chose; the application decides whether to apply it. The theme compares the application's chosen edge with its supplied `nativeEdge`; a mismatch uses the Web rail until the edges match again. For a fixed right-in-LTR rail, pass `{ edge: 'trailing', nativeEdge: verticalBarEdge }` on each `Foldable` update. -Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement(verticalBarEdge)` from its listener. +Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge })` from its listener. On supported iOS versions the runtime hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar; on Web, Android, or when native projection is unavailable, Web clones remain the fallback. Back navigation can come from outside a fixed toolbar; other actions still require one. Fixed-toolbar actions need an icon or SVG, no direct text node, and standard `fill="default"` or `fill="clear"` — text-only actions stay in the original Web toolbar. Add `.ios-theme-horizontal-only` to an `ion-buttons` group or individual `ion-button` to keep it in the horizontal toolbar. Placement is chosen when a routed page enters; changing an existing button's content does not move it between the toolbar and rail until the page leaves and re-enters. Menus, modals, and popovers retain their own toolbar layout. @@ -231,10 +236,11 @@ Stops synchronization, restores Web controls and releases native resources. #### VerticalBarPlacement -| Prop | Type | Description | -| ----------- | ----------------------------------------------------------- | ---------------------------------------------------------- | -| **`edge`** | VerticalBarEdge | | -| **`inset`** | number | UIKit safe-area inset on the vertical-bar edge, in points. | +| Prop | Type | Description | +| ---------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **`edge`** | VerticalBarEdge | | +| **`inset`** | number | Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. | +| **`nativeEdge`** | VerticalBarEdge | Native logical edge reported by the application's device plugin. Null means unavailable; omission keeps the last supplied value. | #### NativeUIShellStatus diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index a2c8e0b5..79522e51 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -340,25 +340,6 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { controller.didMove(toParent: owner) } - // A rendering capability check, not device-state reporting or observation. - // SwiftUI chooses its rail from UIKit traits; edge only selects our hit region. - static func supports(edge: String?, in view: UIView) -> Bool { - guard let edge else { return true } - #if canImport(UIKit, _underlyingVersion: 9127.0.85) && !targetEnvironment(macCatalyst) - if #available(iOS 27.1, *) { - let leading: Bool - switch view.traitCollection.verticalBarEdge { - case .leading: leading = true - case .trailing: leading = false - default: return true - } - let rtl = view.effectiveUserInterfaceLayoutDirection == .rightToLeft - return edge == (leading != rtl ? "left" : "right") - } - #endif - return true - } - func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) { container.railEdge = edge controller.railEdge = edge diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index fd0382f6..77988734 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -193,15 +193,13 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele self.notifyWebViewMetricsChange() let duration = ShellCrossfade.duration(snapshot.transitionDuration) let existing = Set(self.controls.keys) - let verticalBarsSupported = ShellVerticalBarsController.supports( - edge: snapshot.verticalBarEdge, in: self.bridge?.viewController?.view ?? webView) - let verticalBars = verticalBarsSupported ? snapshot.controls.filter { $0.placement == .verticalBars } : [] + let verticalBars = snapshot.controls.filter { $0.placement == .verticalBars } let snapshots = snapshot.controls.filter { $0.placement != .verticalBars } let width = snapshot.viewportWidth self.revision = next if snapshots.isEmpty && verticalBars.isEmpty { self.removeControls(duration: duration) - call.resolve(["revision": next, "verticalBarsSupported": verticalBarsSupported]); return + call.resolve(["revision": next]); return } let scale = webView.bounds.width / width let retained = Set(snapshots.map(\.id)) @@ -228,7 +226,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele if snapshots.isEmpty { self.host?.removeFromSuperview() self.host = nil - call.resolve(["revision": next, "rejectedControls": rejectedControls, "verticalBarsSupported": verticalBarsSupported]) + call.resolve(["revision": next, "rejectedControls": rejectedControls]) return } let host = self.host ?? ShellHost() @@ -358,7 +356,7 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele for (id, control) in self.controls where !existing.contains(id) && self.searchControllers[id] == nil { ShellCrossfade.enter(control, duration: duration) } - let complete = { call.resolve(["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls, "verticalBarsSupported": verticalBarsSupported]) } + let complete = { call.resolve(["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls]) } if let coordinator = searches.first?.0.transitionCoordinator, coordinator.animate(alongsideTransition: nil, completion: { _ in complete() }) { return } complete() diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 574a0b59..23f06c07 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -44,8 +44,10 @@ export type VerticalBarEdge = 'leading' | 'trailing' | null; export interface VerticalBarPlacement { edge: VerticalBarEdge; - /** UIKit safe-area inset on the vertical-bar edge, in points. */ - inset: number; + /** Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. */ + inset?: number; + /** Native logical edge reported by the application's device plugin. Null means unavailable; omission keeps the last supplied value. */ + nativeEdge?: VerticalBarEdge; } export interface VerticalControlAreaHandle extends NativeUIShellHandle { @@ -155,13 +157,7 @@ export interface WebViewMetrics { export interface NativeUIShellPlugin { configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; getWebViewMetrics(): Promise; - update(snapshot: ShellSnapshot): Promise<{ - revision: number; - rejectedSearches?: string[]; - rejectedControls?: string[]; - /** Whether the renderer can honor this snapshot's requested rail edge. */ - verticalBarsSupported?: boolean; - }>; + update(snapshot: ShellSnapshot): Promise<{ revision: number; rejectedSearches?: string[]; rejectedControls?: string[] }>; clear(options: { revision: number }): Promise; addListener(name: 'activate', listener: (event: ShellActivation) => void): Promise; addListener(name: 'search', listener: (event: ShellSearchEvent) => void): Promise; diff --git a/src/native/index.ts b/src/native/index.ts index a183f871..5f3f0031 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -106,6 +106,9 @@ const physicalVerticalBarEdge = (edge: Exclude, rtl: bool const elementRtl = (element: Element): boolean => element.closest('[dir]')?.getAttribute('dir') === 'rtl'; +// Device facts are supplied by the application; the theme only compares placement. +const nativePlacements = new WeakMap(); + /** * Applies one placement to the CSS layout and both Web/native projections. * Pass `rtl` when the document direction is known; otherwise the nearest `dir` attribute is used. @@ -114,11 +117,15 @@ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | Ver if (typeof document === 'undefined') return; const app = document.querySelector('ion-app'); if (!app) throw new Error('Vertical Control Area requires ion-app'); - const { edge, inset } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 }; + const { edge, inset = 0 } = placement && typeof placement === 'object' ? placement : { edge: placement, inset: 0 }; + if (placement && typeof placement === 'object' && placement.nativeEdge !== undefined) { + nativePlacements.set(app, { edge: placement.nativeEdge, rtl }); + } app.classList.toggle('ios-theme-vertical-bars', edge !== null); app.classList.toggle('ios-theme-vertical-bars-left', edge !== null && physicalVerticalBarEdge(edge, rtl ?? elementRtl(app)) === 'left'); if (edge && Number.isFinite(inset) && inset > 0) app.style.setProperty('--ios-theme-vertical-bars-native-inset', `${inset}px`); else app.style.removeProperty('--ios-theme-vertical-bars-native-inset'); + document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh')); }; /** Call once at application startup. Ionic markup remains the source of truth. */ @@ -183,26 +190,21 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true }); if (!capabilities.supported) return fallback('Requires iOS 26 or later'); // The application owns device state and selects the rail through placement classes. - let verticalBarsSupported = true; - const nativeVerticalBars = () => verticalBarsSupported && !!document.querySelector('ion-app.ios-theme-vertical-bars'); + const nativeVerticalBars = () => { + const app = document.querySelector('ion-app.ios-theme-vertical-bars'); + if (!app) return false; + const reported = nativePlacements.get(app); + if (!reported?.edge) return true; + const physicalEdge = app.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right'; + return physicalEdge === physicalVerticalBarEdge(reported.edge, reported.rtl ?? elementRtl(app)); + }; if (!options.verticalBarsOnly) { metricsListener = await plugin.addListener('webViewMetricsChange', (metrics) => { setConfig({ radius: metrics.radius }); }); await configureNativeTransition().catch(() => undefined); } - const native = await createRuntime( - document, - plugin, - options, - nativeVerticalBars, - options.verticalBarsOnly === true, - (supported) => { - if (verticalBarsSupported === supported) return; - verticalBarsSupported = supported; - document.defaultView?.dispatchEvent(new Event('nativeUIShellRefresh')); - }, - ); + const native = await createRuntime(document, plugin, options, nativeVerticalBars, options.verticalBarsOnly === true); runtime = combine( native, createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped'), diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 100901c8..528c0e43 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -44,7 +44,6 @@ export const createRuntime = async ( options: NativeUIShellOptions = {}, nativeVerticalBars: () => boolean = () => true, verticalBarsOnly = false, - onVerticalBarsSupport?: (supported: boolean) => void, ): Promise => { const win = doc.defaultView!; const icons = createIconRenderer(); @@ -345,7 +344,6 @@ export const createRuntime = async ( const result = await bounded(plugin.update(snapshot)); if (stopped) return; if (result.revision !== snapshot.revision) throw new Error('Native UI Shell revision mismatch'); - onVerticalBarsSupport?.(result.verticalBarsSupported !== false); if (`${win.innerWidth}:${win.innerHeight}` !== size) { lastSnapshot = ''; dirty = true; From 6e9b4a151875f6d7babc0f4435c7571d2305a571 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 27 Sep 2026 11:58:10 +0900 Subject: [PATCH 06/12] fix: preserve fold events and handle half-open state without geometry --- demo/src/app/tabs/tabs.page.spec.ts | 76 +++++++++++++++++++++++++++++ demo/src/app/tabs/tabs.page.ts | 7 ++- docs/iphone-duo.md | 18 +++++-- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/demo/src/app/tabs/tabs.page.spec.ts b/demo/src/app/tabs/tabs.page.spec.ts index 0ce47adc..7922962f 100644 --- a/demo/src/app/tabs/tabs.page.spec.ts +++ b/demo/src/app/tabs/tabs.page.spec.ts @@ -1,4 +1,13 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Capacitor } from '@capacitor/core'; +import { type FoldState } from '@erkamyaman/capacitor-foldable'; +import { vi } from 'vitest'; + +const foldable = vi.hoisted(() => ({ + addListener: vi.fn<(event: 'foldStateChange', callback: (fold: FoldState) => void) => Promise<{ remove(): Promise }>>(), + getFoldState: vi.fn<() => Promise>(), +})); +vi.mock('@erkamyaman/capacitor-foldable', () => ({ Foldable: foldable })); import { TabsPage } from './tabs.page'; import { testConfig } from '../../../util/test.config'; @@ -20,7 +29,74 @@ describe('TabsPage', () => { fixture.detectChanges(); }); + afterEach(() => vi.restoreAllMocks()); + it('should create', () => { expect(component).toBeTruthy(); }); + + const flat: FoldState = { state: 'flat', posture: 'flat', isSeparating: false }; + const halfOpened: FoldState = { state: 'half-opened', posture: 'book', isSeparating: true }; + const hingeBounds = { x: 475, y: 0, width: 0, height: 900 }; + + it('uses the fold state without requiring hinge geometry and restores the ordinary layout', () => { + const pane = component.splitPane().nativeElement; + component.setFoldState(halfOpened); + expect(pane.getAttribute('when')).toBe('(min-width: 900px)'); + expect(pane.classList.contains('ios-theme-split-pane-half-open')).toBe(true); + + component.setFoldState({ ...flat, hingeBounds }); + expect(pane.getAttribute('when')).toBe('(min-width: 900px)'); + expect(pane.classList.contains('ios-theme-split-pane-half-open')).toBe(false); + + for (const fold of [flat, { ...flat, state: 'closed' as const, hingeBounds }]) { + component.setFoldState(fold); + expect(pane.getAttribute('when')).toBe('(min-width: 992px)'); + expect(pane.classList.contains('ios-theme-split-pane-half-open')).toBe(false); + } + }); + + it('applies the initial state when no event has arrived', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + foldable.addListener.mockResolvedValue({ remove: vi.fn().mockResolvedValue(undefined) }); + foldable.getFoldState.mockResolvedValue(halfOpened); + await component.observeHinge(); + expect(component.splitPane().nativeElement.getAttribute('when')).toBe('(min-width: 900px)'); + }); + + it('keeps the latest event when an older initial read resolves later', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + let emit!: (fold: FoldState) => void; + foldable.addListener.mockImplementation(async (_event, callback) => { + emit = callback; + return { remove: vi.fn().mockResolvedValue(undefined) }; + }); + let resolveInitial!: (fold: FoldState) => void; + foldable.getFoldState.mockImplementation(() => new Promise((resolve) => (resolveInitial = resolve))); + const observing = component.observeHinge(); + await vi.waitFor(() => expect(resolveInitial).toBeTypeOf('function')); + emit(flat); + emit(halfOpened); + resolveInitial(flat); + await observing; + const pane = component.splitPane().nativeElement; + expect(pane.getAttribute('when')).toBe('(min-width: 900px)'); + expect(pane.classList.contains('ios-theme-split-pane-half-open')).toBe(true); + }); + + it('ignores a pending initial read after destruction and removes its listener', async () => { + vi.spyOn(Capacitor, 'getPlatform').mockReturnValue('ios'); + const remove = vi.fn().mockResolvedValue(undefined); + foldable.addListener.mockResolvedValue({ remove }); + let resolveInitial!: (fold: FoldState) => void; + foldable.getFoldState.mockImplementation(() => new Promise((resolve) => (resolveInitial = resolve))); + const apply = vi.spyOn(component, 'setFoldState'); + const observing = component.observeHinge(); + await vi.waitFor(() => expect(resolveInitial).toBeTypeOf('function')); + component.ngOnDestroy(); + resolveInitial(halfOpened); + await observing; + expect(apply).not.toHaveBeenCalled(); + expect(remove).toHaveBeenCalledOnce(); + }); }); diff --git a/demo/src/app/tabs/tabs.page.ts b/demo/src/app/tabs/tabs.page.ts index a4cbe6b5..c0c89791 100644 --- a/demo/src/app/tabs/tabs.page.ts +++ b/demo/src/app/tabs/tabs.page.ts @@ -69,18 +69,21 @@ export class TabsPage implements OnInit, AfterViewInit, OnDestroy, ViewDidEnter, setFoldState(fold: FoldState) { const splitPane = this.splitPane().nativeElement; // The width rules key off the `when` attribute, so go through setAttribute. - splitPane.setAttribute('when', !fold.hingeBounds ? '(min-width: 992px)' : '(min-width: 900px)'); + const expanded = fold.state === 'half-opened' || (fold.state === 'flat' && !!fold.hingeBounds); + splitPane.setAttribute('when', expanded ? '(min-width: 900px)' : '(min-width: 992px)'); splitPane.classList.toggle('ios-theme-split-pane-half-open', fold.state === 'half-opened'); } async observeHinge() { if (Capacitor.getPlatform() !== 'ios') return; + let receivedEvent = false; this.#hingeListener = await Foldable.addListener('foldStateChange', (fold) => { + receivedEvent = true; if (!this.#destroyed) this.setFoldState(fold); }); if (this.#destroyed) return this.#releaseHinge(); const fold = await Foldable.getFoldState(); - if (!this.#destroyed) this.setFoldState(fold); + if (!this.#destroyed && !receivedEvent) this.setFoldState(fold); } #releaseHinge() { diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md index 73f4a29d..d865a8d8 100644 --- a/docs/iphone-duo.md +++ b/docs/iphone-duo.md @@ -55,16 +55,24 @@ import { Foldable, type FoldState } from '@erkamyaman/capacitor-foldable'; const applyFold = (fold: FoldState) => { const pane = document.querySelector('ion-split-pane'); pane?.classList.toggle('ios-theme-split-pane-half-open', fold.state === 'half-opened'); - pane?.setAttribute('when', fold.hingeBounds ? '(min-width: 900px)' : '(min-width: 992px)'); + const expanded = fold.state === 'half-opened' || (fold.state === 'flat' && !!fold.hingeBounds); + pane?.setAttribute('when', expanded ? '(min-width: 900px)' : '(min-width: 992px)'); }; -const listener = await Foldable.addListener('foldStateChange', applyFold); -applyFold(await Foldable.getFoldState()); +let receivedEvent = false; +let disposed = false; +const listener = await Foldable.addListener('foldStateChange', (fold) => { + receivedEvent = true; + if (!disposed) applyFold(fold); +}); +const initialFold = await Foldable.getFoldState(); +if (!disposed && !receivedEvent) applyFold(initialFold); // When the consumer goes away: +// disposed = true; // await listener.remove(); ``` -`getFoldState()` and `foldStateChange` report `state` (`'flat'`, `'half-opened'`, or `'closed'`), `posture`, and optional hinge geometry. Without fold information, the plugin returns a flat state without `hingeBounds`; restore the ordinary split-pane breakpoint in that case. The Web implementation also returns a flat state. +`getFoldState()` and `foldStateChange` report `state` (`'flat'`, `'half-opened'`, or `'closed'`), `posture`, and optional hinge geometry. Without fold information, the plugin returns a flat state without `hingeBounds`; restore the ordinary split-pane breakpoint in that case. The Web implementation also returns a flat state. A half-opened state uses the 900px breakpoint even without hinge geometry; a flat state with hinge geometry also uses 900px. A closed state restores the ordinary 992px breakpoint. Events received during initialization take precedence over the initial read. `getBarPlacement()` and `barPlacementChange` report `{ verticalBarEdge: 'leading' | 'trailing' | null }`. The edge is **logical**: leading is the physical left in LTR and the physical right in RTL. Pass `{ edge: verticalBarEdge, nativeEdge: verticalBarEdge }` to `setPlacement()`. No start/stop monitoring calls are needed; remove each listener when its owner is disposed. @@ -158,7 +166,7 @@ ion-split-pane { } ``` -The registered `--ios-theme-split-pane-width` defaults to `320px`; `.ios-theme-split-pane-half-open` sets it to `50vw`. Set `halfOpened` when `foldStateChange` reports `state === 'half-opened'` (and read the initial value with `getFoldState()`). Ionic's `when` decides whether the menu is a persistent side pane; choose its breakpoint so the pane is hidden when closed — absent `hingeBounds` means no fold geometry is available, so restore the ordinary breakpoint for it. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout does not enable Vertical Bars or move an overlay menu. +The registered `--ios-theme-split-pane-width` defaults to `320px`; `.ios-theme-split-pane-half-open` sets it to `50vw`. Set `halfOpened` when `foldStateChange` reports `state === 'half-opened'` (and read the initial value with `getFoldState()`). Ionic's `when` decides whether the menu is a persistent side pane; use the 900px breakpoint for a half-opened state or a flat state with hinge geometry, and the ordinary 992px breakpoint when closed or flat without geometry. Missing `hingeBounds` alone does not mean the device is flat. The application chooses where to apply this width rule; an ordinary split pane elsewhere is unchanged. This layout does not enable Vertical Bars or move an overlay menu. ## Vertical Control Area API From 8a42934edd8915968a67154d88aebc17f349788e Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 28 Sep 2026 00:08:24 +0900 Subject: [PATCH 07/12] chore: update capacitor-foldable to 8.3.2 --- README.md | 2 +- demo/README.md | 2 +- demo/package-lock.json | 8 ++++---- demo/package.json | 2 +- docs/iphone-duo-with-original-theme.md | 4 ++-- docs/iphone-duo.md | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b25055b8..c402a8f8 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Keep your existing Ionic theme and move tabs and supported toolbar actions into Device state is supplied by [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable), installed in your app: ```bash -npm install @erkamyaman/capacitor-foldable@^8.3.1 +npm install @erkamyaman/capacitor-foldable@^8.3.2 npx cap sync ``` diff --git a/demo/README.md b/demo/README.md index 7793fd53..30beb553 100644 --- a/demo/README.md +++ b/demo/README.md @@ -20,7 +20,7 @@ npm install 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@^8.3.1 +npm install @erkamyaman/capacitor-foldable@^8.3.2 npx cap sync ``` diff --git a/demo/package-lock.json b/demo/package-lock.json index c896ca4e..3ff0caea 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -22,7 +22,7 @@ "@capacitor/ios": "^8.0.0", "@capacitor/keyboard": "^8.0.0", "@capacitor/status-bar": "^8.0.0", - "@erkamyaman/capacitor-foldable": "^8.3.1", + "@erkamyaman/capacitor-foldable": "^8.3.2", "@ionic/angular": "^9.0.0", "@rdlabo/ionic-theme-ios26": "^9.3.0", "@rdlabo/ionic-theme-ios27": "file:..", @@ -1362,9 +1362,9 @@ } }, "node_modules/@erkamyaman/capacitor-foldable": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@erkamyaman/capacitor-foldable/-/capacitor-foldable-8.3.1.tgz", - "integrity": "sha512-cpuM2nKfzNZT+7ZzTbjzBlkK2Skoff2gdn7yVDy2gbB0dJtZKxCMYWq8rzVbtS2lqMT2OZCtRcgqsvDd+AWIyg==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@erkamyaman/capacitor-foldable/-/capacitor-foldable-8.3.2.tgz", + "integrity": "sha512-YMv3JuFg6UNTDWeAc9DLZyHLKwdXvFOK0C/NA7W4Dy94YKPzENy73ZR0br5S6WnLdwXRxcW+z4yxK8uDBOxlnw==", "license": "MIT", "funding": { "type": "github", diff --git a/demo/package.json b/demo/package.json index 6d60ceee..31d5bdef 100644 --- a/demo/package.json +++ b/demo/package.json @@ -39,7 +39,7 @@ "@capacitor/ios": "^8.0.0", "@capacitor/keyboard": "^8.0.0", "@capacitor/status-bar": "^8.0.0", - "@erkamyaman/capacitor-foldable": "^8.3.1", + "@erkamyaman/capacitor-foldable": "^8.3.2", "@ionic/angular": "^9.0.0", "@rdlabo/ionic-theme-ios26": "^9.3.0", "@rdlabo/ionic-theme-ios27": "file:..", diff --git a/docs/iphone-duo-with-original-theme.md b/docs/iphone-duo-with-original-theme.md index 59aa24cd..3e2cb9ae 100644 --- a/docs/iphone-duo-with-original-theme.md +++ b/docs/iphone-duo-with-original-theme.md @@ -125,7 +125,7 @@ When the application owner is disposed, call `await rail.destroy()` to restore t Install [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable) for device state: ```bash -npm install @erkamyaman/capacitor-foldable@^8.3.1 +npm install @erkamyaman/capacitor-foldable@^8.3.2 npx cap sync ios ``` @@ -156,7 +156,7 @@ const stopVerticalArea = async () => { }; ``` -`setPlacement()` resolves the logical edge through the document direction. Rail width comes from the theme’s CSS safe-area rules; `Foldable` does not supply an inset. A `null` edge restores the ordinary layout. Devices without a rail and apps built with older SDKs report `null`, so this example restores the ordinary layout there. To deliberately preview a DOM rail on such an iOS build, have your application choose a fixed edge with `rail.setPlacement('trailing')` instead of applying that null placement. This simulates the layout; it does not provide a real system rail or hinge measurements. +`setPlacement()` resolves the logical edge through the document direction. Rail width comes from the theme’s CSS safe-area rules; `Foldable` does not supply an inset. A `null` edge restores the ordinary layout. Devices without a reported rail return `null`, so this example restores the ordinary layout there. With Foldable 8.3.2 or later, apps built with older SDKs can infer Duo bar placement from safe-area insets on iOS 27.1 or later. To deliberately request a rail when the plugin reports no edge, have your application choose a fixed edge with `rail.setPlacement('trailing')` instead of applying that null placement. This simulates the layout; it does not provide a real system rail or hinge measurements. On supported iOS, controls in the rail use the system SwiftUI appearance; your custom Web styling still applies to ordinary content and horizontal controls. Web and Android use Web clones. diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md index b25496bf..8b1d0c01 100644 --- a/docs/iphone-duo.md +++ b/docs/iphone-duo.md @@ -41,7 +41,7 @@ The `/vertical-bars` entry point imports `@capacitor/core` at module load, so in Install the device-state plugin in the application, then sync the native project: ```bash -npm install @erkamyaman/capacitor-foldable@^8.3.1 +npm install @erkamyaman/capacitor-foldable@^8.3.2 npx cap sync ``` @@ -80,7 +80,7 @@ The theme never reads UIKit bar-placement traits. The application supplies `nati The plugin does not report a safe-area inset with bar placement. The theme uses CSS safe-area values with its 80px rail fallback; an application can still pass `{ edge, inset }` to `setPlacement()` when it supplies an explicit width. WebView corner radius remains a rendering concern: `configureNativeTransition()` uses the shell's `getWebViewMetrics()` API, independently of `Foldable`. -**Migration:** the theme's former `DeviceLayout`, `HingeStatus`, `getDeviceLayout()`, `deviceLayoutChange`, and start/stop device-layout monitoring APIs have been removed. Replace device subscriptions with the `Foldable` APIs above; use `getWebViewMetrics()` for one-shot radius reads. Apps built without the iOS 27.1 SDK receive no real Duo bar placement or hinge data, but can still simulate a rail using a fixed edge. +**Migration:** the theme's former `DeviceLayout`, `HingeStatus`, `getDeviceLayout()`, `deviceLayoutChange`, and start/stop device-layout monitoring APIs have been removed. Replace device subscriptions with the `Foldable` APIs above; use `getWebViewMetrics()` for one-shot radius reads. With Foldable 8.3.2 or later, apps built without the iOS 27.1 SDK can infer Duo bar placement from safe-area insets. Hinge data still requires the newer SDK. Apps can also request a fixed rail placement independently of the reported edge. ## Reserve the vertical rail From d58c5a28ee3998befed76ad32af1b67ce8cff9da Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 28 Sep 2026 00:09:24 +0900 Subject: [PATCH 08/12] docs: use capacitor-foldable major version in install examples --- README.md | 2 +- demo/README.md | 2 +- docs/iphone-duo-with-original-theme.md | 4 ++-- docs/iphone-duo.md | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c402a8f8..c165170f 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Keep your existing Ionic theme and move tabs and supported toolbar actions into Device state is supplied by [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable), installed in your app: ```bash -npm install @erkamyaman/capacitor-foldable@^8.3.2 +npm install @erkamyaman/capacitor-foldable@8 npx cap sync ``` diff --git a/demo/README.md b/demo/README.md index 30beb553..0d8cf83f 100644 --- a/demo/README.md +++ b/demo/README.md @@ -20,7 +20,7 @@ npm install 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@^8.3.2 +npm install @erkamyaman/capacitor-foldable@8 npx cap sync ``` diff --git a/docs/iphone-duo-with-original-theme.md b/docs/iphone-duo-with-original-theme.md index 3e2cb9ae..df72c598 100644 --- a/docs/iphone-duo-with-original-theme.md +++ b/docs/iphone-duo-with-original-theme.md @@ -125,7 +125,7 @@ When the application owner is disposed, call `await rail.destroy()` to restore t Install [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable) for device state: ```bash -npm install @erkamyaman/capacitor-foldable@^8.3.2 +npm install @erkamyaman/capacitor-foldable@8 npx cap sync ios ``` @@ -156,7 +156,7 @@ const stopVerticalArea = async () => { }; ``` -`setPlacement()` resolves the logical edge through the document direction. Rail width comes from the theme’s CSS safe-area rules; `Foldable` does not supply an inset. A `null` edge restores the ordinary layout. Devices without a reported rail return `null`, so this example restores the ordinary layout there. With Foldable 8.3.2 or later, apps built with older SDKs can infer Duo bar placement from safe-area insets on iOS 27.1 or later. To deliberately request a rail when the plugin reports no edge, have your application choose a fixed edge with `rail.setPlacement('trailing')` instead of applying that null placement. This simulates the layout; it does not provide a real system rail or hinge measurements. +`setPlacement()` resolves the logical edge through the document direction. Rail width comes from the theme’s CSS safe-area rules; `Foldable` does not supply an inset. A `null` edge restores the ordinary layout. Devices without a reported rail return `null`, so this example restores the ordinary layout there. On iOS 27.1 or later, Foldable can infer Duo bar placement from safe-area insets when the app is built with an older SDK. To deliberately request a rail when the plugin reports no edge, have your application choose a fixed edge with `rail.setPlacement('trailing')` instead of applying that null placement. This simulates the layout; it does not provide a real system rail or hinge measurements. On supported iOS, controls in the rail use the system SwiftUI appearance; your custom Web styling still applies to ordinary content and horizontal controls. Web and Android use Web clones. diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md index 8b1d0c01..0c59ac6c 100644 --- a/docs/iphone-duo.md +++ b/docs/iphone-duo.md @@ -41,7 +41,7 @@ The `/vertical-bars` entry point imports `@capacitor/core` at module load, so in Install the device-state plugin in the application, then sync the native project: ```bash -npm install @erkamyaman/capacitor-foldable@^8.3.2 +npm install @erkamyaman/capacitor-foldable@8 npx cap sync ``` @@ -80,7 +80,7 @@ The theme never reads UIKit bar-placement traits. The application supplies `nati The plugin does not report a safe-area inset with bar placement. The theme uses CSS safe-area values with its 80px rail fallback; an application can still pass `{ edge, inset }` to `setPlacement()` when it supplies an explicit width. WebView corner radius remains a rendering concern: `configureNativeTransition()` uses the shell's `getWebViewMetrics()` API, independently of `Foldable`. -**Migration:** the theme's former `DeviceLayout`, `HingeStatus`, `getDeviceLayout()`, `deviceLayoutChange`, and start/stop device-layout monitoring APIs have been removed. Replace device subscriptions with the `Foldable` APIs above; use `getWebViewMetrics()` for one-shot radius reads. With Foldable 8.3.2 or later, apps built without the iOS 27.1 SDK can infer Duo bar placement from safe-area insets. Hinge data still requires the newer SDK. Apps can also request a fixed rail placement independently of the reported edge. +**Migration:** the theme's former `DeviceLayout`, `HingeStatus`, `getDeviceLayout()`, `deviceLayoutChange`, and start/stop device-layout monitoring APIs have been removed. Replace device subscriptions with the `Foldable` APIs above; use `getWebViewMetrics()` for one-shot radius reads. Foldable can infer Duo bar placement from safe-area insets when the app is built without the iOS 27.1 SDK. Hinge data still requires the newer SDK. Apps can also request a fixed rail placement independently of the reported edge. ## Reserve the vertical rail From 0b4c27fcb0cfceb4e021183bd9adf7fc4e261b45 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 28 Sep 2026 00:09:58 +0900 Subject: [PATCH 09/12] docs: omit foldable version from install examples --- README.md | 2 +- demo/README.md | 2 +- docs/iphone-duo-with-original-theme.md | 2 +- docs/iphone-duo.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c165170f..ea6e2e19 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Keep your existing Ionic theme and move tabs and supported toolbar actions into Device state is supplied by [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable), installed in your app: ```bash -npm install @erkamyaman/capacitor-foldable@8 +npm install @erkamyaman/capacitor-foldable npx cap sync ``` diff --git a/demo/README.md b/demo/README.md index 0d8cf83f..5de1ecae 100644 --- a/demo/README.md +++ b/demo/README.md @@ -20,7 +20,7 @@ npm install 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@8 +npm install @erkamyaman/capacitor-foldable npx cap sync ``` diff --git a/docs/iphone-duo-with-original-theme.md b/docs/iphone-duo-with-original-theme.md index df72c598..bf686162 100644 --- a/docs/iphone-duo-with-original-theme.md +++ b/docs/iphone-duo-with-original-theme.md @@ -125,7 +125,7 @@ When the application owner is disposed, call `await rail.destroy()` to restore t Install [`@erkamyaman/capacitor-foldable`](https://github.com/erkamyaman/capacitor-foldable) for device state: ```bash -npm install @erkamyaman/capacitor-foldable@8 +npm install @erkamyaman/capacitor-foldable npx cap sync ios ``` diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md index 0c59ac6c..0a89f7e3 100644 --- a/docs/iphone-duo.md +++ b/docs/iphone-duo.md @@ -41,7 +41,7 @@ The `/vertical-bars` entry point imports `@capacitor/core` at module load, so in Install the device-state plugin in the application, then sync the native project: ```bash -npm install @erkamyaman/capacitor-foldable@8 +npm install @erkamyaman/capacitor-foldable npx cap sync ``` From d5508226097d89a0a25b91a51cd7672d6db86f32 Mon Sep 17 00:00:00 2001 From: Masahiko Sakakibara Date: Mon, 28 Sep 2026 09:52:27 +0900 Subject: [PATCH 10/12] fix: restore mode-specific projection when native rail is unavailable (#232) --- demo/e2e/native-ui-shell.spec.ts | 89 ++++++++++++++++------ demo/src/app/index/index-page.component.ts | 2 +- demo/src/main.ts | 2 +- docs/iphone-duo.md | 12 +-- src/native/definitions.ts | 2 +- src/native/index.ts | 37 ++++++++- 6 files changed, 111 insertions(+), 33 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 5459366b..499fd5dd 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -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, @@ -84,6 +84,7 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra const foldable = { async getBarPlacement() { + if (nativeEdge === 'unreported') return new Promise(() => {}); return { verticalBarEdge: nativeEdge }; }, async getFoldState() { @@ -879,27 +880,69 @@ 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')); - await expect - .poll(() => - page.evaluate(() => - Capacitor.registerPlugin('IonicNativeUIShell') - .updates.at(-1) - ?.controls.some((control: ShellControl) => control.placement === 'vertical-bars'), - ), - ) - .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); -}); +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('IonicNativeUIShell').updates.at(-1)); + const reportEdge = async (verticalBarEdge: 'leading' | 'trailing' | null) => { + await page.evaluate((edge) => { + Capacitor.registerPlugin('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); diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index 94d04cc0..c0d5501e 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -87,7 +87,7 @@ export class IndexPageComponent { readonly #document = inject(DOCUMENT); get verticalBarsModeEnabled() { - return !!this.#document.querySelector('ion-app.ios-theme-vertical-bars'); + return !!this.#document.querySelector('ion-app.ios-theme-vertical-bars, ion-app[data-native-ui-shell-vertical-bars-suspended]'); } async navigateNativeUiShell() { diff --git a/demo/src/main.ts b/demo/src/main.ts index 773c9343..a71f40f8 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -29,7 +29,7 @@ void bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())) const applyPlacement = ({ verticalBarEdge }: BarPlacement) => { const app = document.querySelector('ion-app'); if (!app) return; - const enabled = app.classList.contains('ios-theme-vertical-bars'); + const enabled = app.classList.contains('ios-theme-vertical-bars') || app.hasAttribute('data-native-ui-shell-vertical-bars-suspended'); const rtl = app.closest('[dir]')?.getAttribute('dir') === 'rtl'; const current = app.classList.contains('ios-theme-vertical-bars-left') !== rtl ? 'leading' : 'trailing'; setVerticalControlAreaPlacement({ edge: enabled ? (verticalBarEdge ?? current) : null, nativeEdge: verticalBarEdge }); diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md index 0a89f7e3..80c9a367 100644 --- a/docs/iphone-duo.md +++ b/docs/iphone-duo.md @@ -76,7 +76,7 @@ if (!disposed && !receivedEvent) applyFold(initialFold); `getBarPlacement()` and `barPlacementChange` report `{ verticalBarEdge: 'leading' | 'trailing' | null }`. The edge is **logical**: leading is the physical left in LTR and the physical right in RTL. Pass `{ edge: verticalBarEdge, nativeEdge: verticalBarEdge }` to `setPlacement()`. No start/stop monitoring calls are needed; remove each listener when its owner is disposed. -The theme never reads UIKit bar-placement traits. The application supplies `nativeEdge` on the initial read and each event, even when choosing a fixed `edge`. Omit `nativeEdge` to keep the last supplied value; pass `null` when the plugin reports no edge. Without a reported edge, the runtime follows the application's classes for previews and older SDKs. Passing `{ edge: null, nativeEdge }` updates the reported edge while keeping the rail disabled. +The theme never reads UIKit bar-placement traits. The application supplies `nativeEdge` on the initial read and each event, even when choosing a fixed `edge`. Omit `nativeEdge` to keep the last supplied value; pass `null` when the plugin reports no edge. An explicit `nativeEdge: null` or an initial unregistered edge prevents native vertical projection: `enableVerticalControlArea()` keeps the requested Web rail, while the full Native UI Shell temporarily restores its ordinary horizontal layout. The requested rail is retained so a later reported edge can restore vertical layout. Native vertical projection starts only after a matching non-null edge is supplied. Omitting `nativeEdge` after supplying it preserves that value, including `null`. Passing `{ edge: null, nativeEdge }` updates the reported edge while keeping the rail disabled. The plugin does not report a safe-area inset with bar placement. The theme uses CSS safe-area values with its 80px rail fallback; an application can still pass `{ edge, inset }` to `setPlacement()` when it supplies an explicit width. WebView corner radius remains a rendering concern: `configureNativeTransition()` uses the shell's `getWebViewMetrics()` API, independently of `Foldable`. @@ -274,11 +274,11 @@ Stops synchronization, restores Web controls and releases native resources. #### VerticalBarPlacement -| Prop | Type | Description | -| ---------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| **`edge`** | VerticalBarEdge | | -| **`inset`** | number | Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. | -| **`nativeEdge`** | VerticalBarEdge | Native logical edge reported by the application's device plugin. Null means unavailable; omission keeps the last supplied value. | +| Prop | Type | Description | +| ---------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`edge`** | VerticalBarEdge | | +| **`inset`** | number | Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. | +| **`nativeEdge`** | VerticalBarEdge | Native logical edge reported by the application's device plugin. Null or an unregistered edge uses a Web rail in verticalBarsOnly mode, or the ordinary Native UI Shell layout otherwise. Omission keeps the last supplied value. | #### NativeUIShellStatus diff --git a/src/native/definitions.ts b/src/native/definitions.ts index 2ce73610..dd1f3a17 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -46,7 +46,7 @@ export interface VerticalBarPlacement { edge: VerticalBarEdge; /** Explicit rail width in CSS pixels; omitted to use the stylesheet's safe-area rules. */ inset?: number; - /** Native logical edge reported by the application's device plugin. Null means unavailable; omission keeps the last supplied value. */ + /** Native logical edge reported by the application's device plugin. Null or an unregistered edge uses a Web rail in verticalBarsOnly mode, or the ordinary Native UI Shell layout otherwise. Omission keeps the last supplied value. */ nativeEdge?: VerticalBarEdge; } diff --git a/src/native/index.ts b/src/native/index.ts index ef04c072..20e41973 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -109,6 +109,36 @@ const elementRtl = (element: Element): boolean => element.closest('[dir]')?.getA // Device facts are supplied by the application; the theme only compares placement. const nativePlacements = new WeakMap(); +const horizontalFallbackAttribute = 'data-native-ui-shell-vertical-bars-suspended'; + +// Keep the requested rail while ordinary Native UI Shell temporarily uses its +// horizontal layout. Removing the effective class restores normal measurements, +// toolbar ownership and safe areas throughout the existing rendering pipeline. +const observeNativeVerticalBarsLayout = (doc: Document): (() => void) => { + const reconcile = () => { + const app = doc.querySelector('ion-app'); + if (!app) return; + const requested = app.classList.contains('ios-theme-vertical-bars') || app.hasAttribute(horizontalFallbackAttribute); + const suspended = requested && nativePlacements.get(app)?.edge == null; + if (app.hasAttribute(horizontalFallbackAttribute) !== suspended) app.toggleAttribute(horizontalFallbackAttribute, suspended); + if (app.classList.contains('ios-theme-vertical-bars') !== (requested && !suspended)) { + app.classList.toggle('ios-theme-vertical-bars', requested && !suspended); + } + }; + const observer = new MutationObserver(reconcile); + observer.observe(doc.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }); + doc.defaultView?.addEventListener('nativeUIShellRefresh', reconcile); + reconcile(); + return () => { + observer.disconnect(); + doc.defaultView?.removeEventListener('nativeUIShellRefresh', reconcile); + const app = doc.querySelector(`ion-app[${horizontalFallbackAttribute}]`); + if (app) { + app.removeAttribute(horizontalFallbackAttribute); + app.classList.add('ios-theme-vertical-bars'); + } + }; +}; /** * Applies one placement to the CSS layout and both Web/native projections. @@ -122,6 +152,7 @@ export const setVerticalControlAreaPlacement = (placement: VerticalBarEdge | Ver if (placement && typeof placement === 'object' && placement.nativeEdge !== undefined) { nativePlacements.set(app, { edge: placement.nativeEdge, rtl }); } + app.removeAttribute(horizontalFallbackAttribute); app.classList.toggle('ios-theme-vertical-bars', edge !== null); app.classList.toggle('ios-theme-vertical-bars-left', edge !== null && physicalVerticalBarEdge(edge, rtl ?? elementRtl(app)) === 'left'); if (edge && Number.isFinite(inset) && inset > 0) app.style.setProperty('--ios-theme-vertical-bars-native-inset', `${inset}px`); @@ -191,15 +222,17 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS'); let runtime: NativeUIShellHandle | undefined; let metricsListener: Awaited> | undefined; + let stopVerticalBarsLayout: (() => void) | undefined; try { const capabilities = await plugin.configure({ verticalBarsOnly: options.verticalBarsOnly === true }); if (!capabilities.supported) return fallback('Requires iOS 26 or later'); + if (!options.verticalBarsOnly) stopVerticalBarsLayout = observeNativeVerticalBarsLayout(document); // The application owns device state and selects the rail through placement classes. const nativeVerticalBars = () => { const app = document.querySelector('ion-app.ios-theme-vertical-bars'); if (!app) return false; const reported = nativePlacements.get(app); - if (!reported?.edge) return true; + if (!reported?.edge) return false; const physicalEdge = app.classList.contains('ios-theme-vertical-bars-left') ? 'left' : 'right'; return physicalEdge === physicalVerticalBarEdge(reported.edge, reported.rtl ?? elementRtl(app)); }; @@ -219,6 +252,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise release, destroy: async () => { await metricsListener?.remove().catch(() => {}); + stopVerticalBarsLayout?.(); prehide?.stop(); stopModals(); }, @@ -226,6 +260,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise } catch (error) { await runtime?.destroy(); await metricsListener?.remove().catch(() => {}); + stopVerticalBarsLayout?.(); return fallback(error instanceof Error ? error.message : String(error)); } })()); From 1eb716ccae0bb31bc608ce721e7aac8d00370f9f Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 28 Sep 2026 10:05:04 +0900 Subject: [PATCH 11/12] feat: apply measured foldable rail insets --- demo/e2e/native-ui-shell.spec.ts | 34 +++++++++++++++++++++- demo/package-lock.json | 8 ++--- demo/package.json | 2 +- demo/src/app/index/index-page.component.ts | 4 +-- demo/src/main.ts | 4 +-- docs/iphone-duo-with-original-theme.md | 10 +++---- docs/iphone-duo.md | 14 ++++----- 7 files changed, 54 insertions(+), 22 deletions(-) diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index 499fd5dd..dc9344fd 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -85,7 +85,7 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra const foldable = { async getBarPlacement() { if (nativeEdge === 'unreported') return new Promise(() => {}); - return { verticalBarEdge: nativeEdge }; + return { verticalBarEdge: nativeEdge, inset: nativeEdge ? 84 : 0 }; }, async getFoldState() { return { state: 'flat', isSeparating: false, posture: 'flat' }; @@ -880,6 +880,38 @@ 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); }); +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('Foldable').notifyListeners('barPlacementChange', { verticalBarEdge, inset }); + }, + { verticalBarEdge, inset }, + ); + }; + await report('trailing', 64); + await expect.poll(width).toBe('64px'); + await expect + .poll(() => + app.evaluate((element) => getComputedStyle(element).getPropertyValue('--ios-theme-vertical-bars-safe-area-right-resolved').trim()), + ) + .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 ({ diff --git a/demo/package-lock.json b/demo/package-lock.json index 3ff0caea..2a4bb1a7 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -22,7 +22,7 @@ "@capacitor/ios": "^8.0.0", "@capacitor/keyboard": "^8.0.0", "@capacitor/status-bar": "^8.0.0", - "@erkamyaman/capacitor-foldable": "^8.3.2", + "@erkamyaman/capacitor-foldable": "^8.3.3", "@ionic/angular": "^9.0.0", "@rdlabo/ionic-theme-ios26": "^9.3.0", "@rdlabo/ionic-theme-ios27": "file:..", @@ -1362,9 +1362,9 @@ } }, "node_modules/@erkamyaman/capacitor-foldable": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/@erkamyaman/capacitor-foldable/-/capacitor-foldable-8.3.2.tgz", - "integrity": "sha512-YMv3JuFg6UNTDWeAc9DLZyHLKwdXvFOK0C/NA7W4Dy94YKPzENy73ZR0br5S6WnLdwXRxcW+z4yxK8uDBOxlnw==", + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/@erkamyaman/capacitor-foldable/-/capacitor-foldable-8.3.3.tgz", + "integrity": "sha512-gYqldze2D4ikk9OjB2ZWvy9I+nfozn5K4OOoDKb944EjivnokFVhejiMQiguum/J934aa58enwrbMZ/0yWHPNw==", "license": "MIT", "funding": { "type": "github", diff --git a/demo/package.json b/demo/package.json index 31d5bdef..ac6920ca 100644 --- a/demo/package.json +++ b/demo/package.json @@ -39,7 +39,7 @@ "@capacitor/ios": "^8.0.0", "@capacitor/keyboard": "^8.0.0", "@capacitor/status-bar": "^8.0.0", - "@erkamyaman/capacitor-foldable": "^8.3.2", + "@erkamyaman/capacitor-foldable": "^8.3.3", "@ionic/angular": "^9.0.0", "@rdlabo/ionic-theme-ios26": "^9.3.0", "@rdlabo/ionic-theme-ios27": "file:..", diff --git a/demo/src/app/index/index-page.component.ts b/demo/src/app/index/index-page.component.ts index c0d5501e..31324e96 100644 --- a/demo/src/app/index/index-page.component.ts +++ b/demo/src/app/index/index-page.component.ts @@ -104,7 +104,7 @@ export class IndexPageComponent { async changeVerticalBarsMode(event: ToggleCustomEvent) { if (!event.detail.checked) return setVerticalControlAreaPlacement(null); - const { verticalBarEdge } = await Foldable.getBarPlacement(); - setVerticalControlAreaPlacement({ edge: verticalBarEdge ?? 'trailing', nativeEdge: verticalBarEdge }); + const { verticalBarEdge, inset } = await Foldable.getBarPlacement(); + setVerticalControlAreaPlacement({ edge: verticalBarEdge ?? 'trailing', nativeEdge: verticalBarEdge, inset }); } } diff --git a/demo/src/main.ts b/demo/src/main.ts index a71f40f8..9d9fb805 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -26,13 +26,13 @@ function loadIOSAnimations(): IonicAnimationOptions { void bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())) .then(async () => { if (Capacitor.getPlatform() !== 'ios') return; - const applyPlacement = ({ verticalBarEdge }: BarPlacement) => { + const applyPlacement = ({ verticalBarEdge, inset }: BarPlacement) => { const app = document.querySelector('ion-app'); if (!app) return; const enabled = app.classList.contains('ios-theme-vertical-bars') || app.hasAttribute('data-native-ui-shell-vertical-bars-suspended'); const rtl = app.closest('[dir]')?.getAttribute('dir') === 'rtl'; const current = app.classList.contains('ios-theme-vertical-bars-left') !== rtl ? 'leading' : 'trailing'; - setVerticalControlAreaPlacement({ edge: enabled ? (verticalBarEdge ?? current) : null, nativeEdge: verticalBarEdge }); + setVerticalControlAreaPlacement({ edge: enabled ? (verticalBarEdge ?? current) : null, nativeEdge: verticalBarEdge, inset }); }; await Foldable.addListener('barPlacementChange', applyPlacement); applyPlacement(await Foldable.getBarPlacement()); diff --git a/docs/iphone-duo-with-original-theme.md b/docs/iphone-duo-with-original-theme.md index bf686162..db044d76 100644 --- a/docs/iphone-duo-with-original-theme.md +++ b/docs/iphone-duo-with-original-theme.md @@ -142,11 +142,11 @@ const rail = await enableVerticalControlArea(); let layoutListener: PluginListenerHandle | undefined; if (Capacitor.getPlatform() === 'ios') { - layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge }) => - rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge }), + layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge, inset }) => + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }), ); - const { verticalBarEdge } = await Foldable.getBarPlacement(); - rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge }); + const { verticalBarEdge, inset } = await Foldable.getBarPlacement(); + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }); } // Call when the application owner is disposed. @@ -156,7 +156,7 @@ const stopVerticalArea = async () => { }; ``` -`setPlacement()` resolves the logical edge through the document direction. Rail width comes from the theme’s CSS safe-area rules; `Foldable` does not supply an inset. A `null` edge restores the ordinary layout. Devices without a reported rail return `null`, so this example restores the ordinary layout there. On iOS 27.1 or later, Foldable can infer Duo bar placement from safe-area insets when the app is built with an older SDK. To deliberately request a rail when the plugin reports no edge, have your application choose a fixed edge with `rail.setPlacement('trailing')` instead of applying that null placement. This simulates the layout; it does not provide a real system rail or hinge measurements. +`setPlacement()` resolves the logical edge through the document direction. Pass Foldable’s measured `inset` with the reported edge so the theme reserves the actual bar width and follows width changes. An inset of `0` clears the explicit width; manually requested rails then use the theme’s CSS safe-area rules. A `null` edge restores the ordinary layout. Devices without a reported rail return `null`, so this example restores the ordinary layout there. On iOS 27.1 or later, Foldable can infer Duo bar placement from safe-area insets when the app is built with an older SDK. To deliberately request a rail when the plugin reports no edge, have your application choose a fixed edge with `rail.setPlacement('trailing')` instead of applying that null placement. This simulates the layout; it does not provide a real system rail or hinge measurements. On supported iOS, controls in the rail use the system SwiftUI appearance; your custom Web styling still applies to ordinary content and horizontal controls. Web and Android use Web clones. diff --git a/docs/iphone-duo.md b/docs/iphone-duo.md index 80c9a367..62a6aa59 100644 --- a/docs/iphone-duo.md +++ b/docs/iphone-duo.md @@ -74,11 +74,11 @@ if (!disposed && !receivedEvent) applyFold(initialFold); `getFoldState()` and `foldStateChange` report `state` (`'flat'`, `'half-opened'`, or `'closed'`), `posture`, and optional hinge geometry. Without fold information, the plugin returns a flat state without `hingeBounds`; restore the ordinary split-pane breakpoint in that case. The Web implementation also returns a flat state. A half-opened state uses the 900px breakpoint even without hinge geometry; a flat state with hinge geometry also uses 900px. A closed state restores the ordinary 992px breakpoint. Events received during initialization take precedence over the initial read. -`getBarPlacement()` and `barPlacementChange` report `{ verticalBarEdge: 'leading' | 'trailing' | null }`. The edge is **logical**: leading is the physical left in LTR and the physical right in RTL. Pass `{ edge: verticalBarEdge, nativeEdge: verticalBarEdge }` to `setPlacement()`. No start/stop monitoring calls are needed; remove each listener when its owner is disposed. +`getBarPlacement()` and `barPlacementChange` report `{ verticalBarEdge: 'leading' | 'trailing' | null, inset: number }`. The edge is **logical**: leading is the physical left in LTR and the physical right in RTL. Pass `{ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }` to `setPlacement()`. No start/stop monitoring calls are needed; remove each listener when its owner is disposed. The theme never reads UIKit bar-placement traits. The application supplies `nativeEdge` on the initial read and each event, even when choosing a fixed `edge`. Omit `nativeEdge` to keep the last supplied value; pass `null` when the plugin reports no edge. An explicit `nativeEdge: null` or an initial unregistered edge prevents native vertical projection: `enableVerticalControlArea()` keeps the requested Web rail, while the full Native UI Shell temporarily restores its ordinary horizontal layout. The requested rail is retained so a later reported edge can restore vertical layout. Native vertical projection starts only after a matching non-null edge is supplied. Omitting `nativeEdge` after supplying it preserves that value, including `null`. Passing `{ edge: null, nativeEdge }` updates the reported edge while keeping the rail disabled. -The plugin does not report a safe-area inset with bar placement. The theme uses CSS safe-area values with its 80px rail fallback; an application can still pass `{ edge, inset }` to `setPlacement()` when it supplies an explicit width. WebView corner radius remains a rendering concern: `configureNativeTransition()` uses the shell's `getWebViewMetrics()` API, independently of `Foldable`. +Foldable reports the measured width reserved by the native bar as `inset` and notifies changes through `barPlacementChange`. Pass this value to `setPlacement()` when following the reported edge, rather than assuming a fixed width. When no bar is reported, `inset` is `0`; the theme clears the explicit width and uses its CSS safe-area rules if the application still requests a Web rail. Applications choosing a different edge can supply their own width or use the CSS fallback. WebView corner radius remains a rendering concern: `configureNativeTransition()` uses the shell's `getWebViewMetrics()` API, independently of `Foldable`. **Migration:** the theme's former `DeviceLayout`, `HingeStatus`, `getDeviceLayout()`, `deviceLayoutChange`, and start/stop device-layout monitoring APIs have been removed. Replace device subscriptions with the `Foldable` APIs above; use `getWebViewMetrics()` for one-shot radius reads. Foldable can infer Duo bar placement from safe-area insets when the app is built without the iOS 27.1 SDK. Hinge data still requires the newer SDK. Apps can also request a fixed rail placement independently of the reported edge. @@ -114,11 +114,11 @@ const rail = await enableVerticalControlArea(); let layoutListener: PluginListenerHandle | undefined; if (Capacitor.getPlatform() === 'ios') { - layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge }) => - rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge }), + layoutListener = await Foldable.addListener('barPlacementChange', ({ verticalBarEdge, inset }) => + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }), ); - const { verticalBarEdge } = await Foldable.getBarPlacement(); - rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge }); + const { verticalBarEdge, inset } = await Foldable.getBarPlacement(); + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }); } // Call when the application owner is disposed. @@ -134,7 +134,7 @@ const stopVerticalArea = async () => { - Pass `null` to restore the ordinary layout. - The device-layout listener reports what iOS chose; the application decides whether to apply it. The theme compares the application's chosen edge with its supplied `nativeEdge`; a mismatch uses the Web rail until the edges match again. For a fixed right-in-LTR rail, pass `{ edge: 'trailing', nativeEdge: verticalBarEdge }` on each `Foldable` update. -Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge })` from its listener. +Start either `enableVerticalControlArea()` or the full `enableNativeUIShell()` — not both. Repeating the same configuration returns the shared runtime; starting a different configuration while it is active throws an error. The application should have one owner responsible for destroying that runtime. If the app already uses `enableNativeUIShell()`, keep that single runtime and call `setVerticalControlAreaPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset })` from its listener. On supported iOS versions the runtime hands eligible tabs, back navigation, menu buttons, and fixed-toolbar actions to a native SwiftUI `TabView` and toolbar; on Web, Android, or when native projection is unavailable, Web clones remain the fallback. Back navigation can come from outside a fixed toolbar; menu buttons and other toolbar actions still require one. From 9e95ce952816d31e32d938b8955af4e4259a2905 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Mon, 28 Sep 2026 10:14:24 +0900 Subject: [PATCH 12/12] fix: serve demo images locally for reliable screenshots --- demo/src/app/index/pages/card/card.page.html | 10 +- demo/src/app/index/pages/chip/chip.page.html | 4 +- demo/src/assets/ionic-demos/LICENSE | 190 +++++++++++++++++++ demo/src/assets/ionic-demos/README.md | 1 + demo/src/assets/ionic-demos/avatar.svg | 12 ++ demo/src/assets/ionic-demos/card-media.png | Bin 0 -> 14847 bytes demo/src/assets/ionic-demos/thumbnail.svg | 13 ++ 7 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 demo/src/assets/ionic-demos/LICENSE create mode 100644 demo/src/assets/ionic-demos/README.md create mode 100644 demo/src/assets/ionic-demos/avatar.svg create mode 100644 demo/src/assets/ionic-demos/card-media.png create mode 100644 demo/src/assets/ionic-demos/thumbnail.svg diff --git a/demo/src/app/index/pages/card/card.page.html b/demo/src/app/index/pages/card/card.page.html index c891e6a2..cb980457 100644 --- a/demo/src/app/index/pages/card/card.page.html +++ b/demo/src/app/index/pages/card/card.page.html @@ -33,7 +33,7 @@

card

Here's a small text description for the card content. Nothing more, nothing less. - Silhouette of mountains + Silhouette of mountains Card Title Card Subtitle @@ -64,28 +64,28 @@

card

- Silhouette of mountains + Silhouette of mountains Item - Silhouette of mountains + Silhouette of mountains Item - Silhouette of mountains + Silhouette of mountains Item - Silhouette of mountains + Silhouette of mountains Item diff --git a/demo/src/app/index/pages/chip/chip.page.html b/demo/src/app/index/pages/chip/chip.page.html index 9640b69e..879f5f46 100644 --- a/demo/src/app/index/pages/chip/chip.page.html +++ b/demo/src/app/index/pages/chip/chip.page.html @@ -29,7 +29,7 @@

chip

Outline - Silhouette of a person's head + Silhouette of a person's head Avatar Chip @@ -47,7 +47,7 @@

chip

Outline - Silhouette of a person's head + Silhouette of a person's head Avatar Chip diff --git a/demo/src/assets/ionic-demos/LICENSE b/demo/src/assets/ionic-demos/LICENSE new file mode 100644 index 00000000..022e9472 --- /dev/null +++ b/demo/src/assets/ionic-demos/LICENSE @@ -0,0 +1,190 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2016 Drifty Co. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/demo/src/assets/ionic-demos/README.md b/demo/src/assets/ionic-demos/README.md new file mode 100644 index 00000000..a3a00631 --- /dev/null +++ b/demo/src/assets/ionic-demos/README.md @@ -0,0 +1 @@ +These unmodified demo images are vendored from [ionic-team/ionic-docs](https://github.com/ionic-team/ionic-docs/tree/226ce07e4b2e73ab556a61dd55c9dc4a87a4a48d/static/img/demos) under the Apache License 2.0 (see LICENSE). Local copies keep the demo and screenshot tests independent of the documentation website. diff --git a/demo/src/assets/ionic-demos/avatar.svg b/demo/src/assets/ionic-demos/avatar.svg new file mode 100644 index 00000000..bd0cec9a --- /dev/null +++ b/demo/src/assets/ionic-demos/avatar.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/demo/src/assets/ionic-demos/card-media.png b/demo/src/assets/ionic-demos/card-media.png new file mode 100644 index 0000000000000000000000000000000000000000..1a5bd321b01a92572d20dde93b06578317e72aea GIT binary patch literal 14847 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 JYQi}4e*m^z@umO( literal 0 HcmV?d00001 diff --git a/demo/src/assets/ionic-demos/thumbnail.svg b/demo/src/assets/ionic-demos/thumbnail.svg new file mode 100644 index 00000000..b12a43f9 --- /dev/null +++ b/demo/src/assets/ionic-demos/thumbnail.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + +