diff --git a/README.md b/README.md index 823fcba4..ea6e2e19 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 +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..5de1ecae 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 +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 ba61fdbb..dc9344fd 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, @@ -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,19 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra }, }; + const foldable = { + async getBarPlacement() { + if (nativeEdge === 'unreported') return new Promise(() => {}); + return { verticalBarEdge: nativeEdge, inset: nativeEdge ? 84 : 0 }; + }, + async getFoldState() { + return { state: 'flat', isSeparating: false, posture: 'flat' }; + }, + listeners: {} as Record 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 +105,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; }, }); @@ -816,8 +820,7 @@ test('verticalBars rail remains native while its Ionic menu is open', async ({ p await expect(morphedCancel).toBeVisible(); }); -// An OS-reported edge that disagrees with the DOM strip is the only supported -// "native rail unavailable" state; the Web fallback then owns the rail. +// A renderer that cannot honor the requested edge hands the rail back to the Web. test('verticalBars controls stay operable on Web when the reported rail edge differs', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockNative(page, false, 'leading'); @@ -848,9 +851,9 @@ test('verticalBars controls stay operable on Web when the reported rail edge dif 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); @@ -877,28 +880,102 @@ test('verticalBars controls stay operable on Web when the reported rail edge dif expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).verticalBarsBackCloneMoved)).toBe(false); }); -// Apps linked against an SDK older than 27.1 never get a trait-reported edge; -// the DOM strip still owns the layout, so the native rail follows it. -test('verticalBars controls project natively when the OS reports no rail edge', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }); - await mockNative(page, false, null); - await page.goto('/main/index/native-ui-shell'); - await page.locator('app-native-ui-shell ion-menu-button').evaluate((element: HTMLIonMenuButtonElement) => (element.autoHide = false)); - await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); +test('reported rail inset updates the layout without an edge change and clears when unavailable', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index?verticalBarsOnly'); + const app = page.locator('ion-app'); + await page.getByText('iPhone Duo Mode', { exact: true }).click(); + const width = () => app.evaluate((element) => element.style.getPropertyValue('--ios-theme-vertical-bars-native-inset')); + await expect.poll(width).toBe('84px'); + + const report = async (verticalBarEdge: 'leading' | 'trailing' | null, inset: number) => { + await page.evaluate( + ({ verticalBarEdge, inset }) => { + Capacitor.registerPlugin('Foldable').notifyListeners('barPlacementChange', { verticalBarEdge, inset }); + }, + { verticalBarEdge, inset }, + ); + }; + await report('trailing', 64); + await expect.poll(width).toBe('64px'); await expect .poll(() => - page.evaluate(() => - Capacitor.registerPlugin('IonicNativeUIShell') - .updates.at(-1) - ?.controls.some((control: ShellControl) => control.placement === 'vertical-bars'), - ), + app.evaluate((element) => getComputedStyle(element).getPropertyValue('--ios-theme-vertical-bars-safe-area-right-resolved').trim()), ) - .toBe(true); - await expect(page.locator('ion-app > ion-back-button.ios-theme-vertical-bars-back-button-projection')).toHaveCount(0); - await expect(page.locator('ion-app > ion-menu-button.ios-theme-vertical-bars-toolbar-projection')).toHaveCount(0); - await expect(page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label=Save]')).toHaveCount(0); + .toBe('64px'); + await report('leading', 96); + await expect(app).toHaveClass(/ios-theme-vertical-bars-left/); + await expect.poll(width).toBe('96px'); + await report(null, 0); + await expect.poll(width).toBe(''); + await expect(app).toHaveClass(/ios-theme-vertical-bars/); + await expect(page.locator('ion-tab-bar')).toBeVisible(); }); +for (const initialEdge of [null, 'unreported'] as const) { + for (const verticalBarsOnly of [true, false]) { + test(`${initialEdge ?? 'null'} native edge restores ${verticalBarsOnly ? 'Web rail' : 'ordinary Native UI Shell'} and recovers`, async ({ + page, + }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page, false, initialEdge); + await page.goto(`/main/index/native-ui-shell${verticalBarsOnly ? '?verticalBarsOnly' : ''}`); + await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => { + icon.setAttribute('slot', 'icon-only'); + icon.parentElement!.querySelector('[data-label]')?.remove(); + }); + const app = page.locator('ion-app'); + const tabs = page.locator('ion-tab-bar'); + const save = page.locator('app-native-ui-shell ion-button[type=submit]'); + const clone = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label=Save]'); + const snapshot = () => page.evaluate(() => Capacitor.registerPlugin('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); await page.goto('/main/index/native-ui-shell'); @@ -2781,6 +2858,33 @@ test('rejected search retries when tab content changes without resizing', async expect(await page.locator('ion-tab-bar').boundingBox()).toEqual(before); }); +test('verticalBars return to native projection when the requested edge matches after rotation', async ({ page }) => { + await mockNative(page, false, 'leading'); + await page.goto('/main/index/native-ui-shell'); + await page.locator('app-native-ui-shell ion-button[type=submit] ion-icon').evaluate((icon) => { + icon.setAttribute('slot', 'icon-only'); + icon.parentElement!.querySelector('[data-label]')?.remove(); + }); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + const source = page.locator('app-native-ui-shell ion-button[type=submit]'); + const clone = page.locator('ion-app > ion-button.ios-theme-vertical-bars-toolbar-projection[aria-label=Save]'); + await expect(clone).toBeVisible(); + const rotate = () => + page.evaluate(() => { + Capacitor.registerPlugin<{ notifyListeners(name: string, value: unknown): void }>('Foldable').notifyListeners('barPlacementChange', { + verticalBarEdge: 'trailing', + }); + }); + await rotate(); + await expect(clone).toHaveCount(0); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); + await page.locator('ion-app').evaluate((element) => element.setAttribute('dir', 'rtl')); + await expect(clone).toBeVisible(); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars-left')); + await expect(clone).toHaveCount(0); + await expect(source).toHaveAttribute('data-native-ui-shell', ''); +}); + for (const type of ['normal', 'card', 'sheet']) { test(`verticalBars native controls follow the foreground ${type} modal`, async ({ page }) => { await page.setViewportSize({ width: 700, height: 900 }); 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..2a4bb1a7 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.3", "@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.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", + "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..ac6920ca 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.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 c5c7dfda..31324e96 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 '../../../../src/vertical-bars'; +import { Foldable } from '@erkamyaman/capacitor-foldable'; interface IComponent { name: string; @@ -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() { @@ -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, inset } = await Foldable.getBarPlacement(); + setVerticalControlAreaPlacement({ edge: verticalBarEdge ?? 'trailing', nativeEdge: verticalBarEdge, inset }); } } 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/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 65a58f94..c0c89791 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,32 @@ 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); + 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; - 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); + let receivedEvent = false; + this.#hingeListener = await Foldable.addListener('foldStateChange', (fold) => { + receivedEvent = true; + 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 && !receivedEvent) 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/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 00000000..1a5bd321 Binary files /dev/null and b/demo/src/assets/ionic-demos/card-media.png differ 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 @@ + + + + + + + + + + + + + diff --git a/demo/src/main.ts b/demo/src/main.ts index 54014833..9d9fb805 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, type BarPlacement } from '@erkamyaman/capacitor-foldable'; import { iosTransitionAnimation, popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios27'; /** @@ -25,14 +26,16 @@ function loadIOSAnimations(): IonicAnimationOptions { void bootstrapApplication(AppComponent, createAppConfig(loadIOSAnimations())) .then(async () => { if (Capacitor.getPlatform() !== 'ios') return; - await IonicNativeUIShell.startDeviceLayoutMonitoring(); - await IonicNativeUIShell.addListener('deviceLayoutChange', ({ placement }) => { - const app = document.querySelector('ion-app.ios-theme-vertical-bars'); + 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(placement.edge ? placement : current); - }); + setVerticalControlAreaPlacement({ edge: enabled ? (verticalBarEdge ?? current) : null, nativeEdge: verticalBarEdge, inset }); + }; + 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 f83caf3f..db044d76 100644 --- a/docs/iphone-duo-with-original-theme.md +++ b/docs/iphone-duo-with-original-theme.md @@ -122,23 +122,31 @@ 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 +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, inset }) => + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }), ); - rail.setPlacement((await IonicNativeUIShell.getDeviceLayout()).placement); + const { verticalBarEdge, inset } = await Foldable.getBarPlacement(); + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }); } // Call when the application owner is disposed. @@ -148,13 +156,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. 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. ## 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 d3047568..62a6aa59 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,49 @@ 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 +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'); + const expanded = fold.state === 'half-opened' || (fold.state === 'flat' && !!fold.hingeBounds); + pane?.setAttribute('when', expanded ? '(min-width: 900px)' : '(min-width: 992px)'); +}; +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(); ``` -`DeviceLayout` carries: +`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. -| 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 | +`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. -`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. +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. -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. +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`. -**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. 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 @@ -81,9 +90,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 +106,19 @@ 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, inset }) => + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }), + ); + const { verticalBarEdge, inset } = await Foldable.getBarPlacement(); + rail.setPlacement({ edge: verticalBarEdge, nativeEdge: verticalBarEdge, inset }); } // Call when the application owner is disposed. @@ -118,11 +130,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 `{ 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. 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(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({ 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. @@ -184,7 +196,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; 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 @@ -262,10 +274,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 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/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index c7e4473e..b345745d 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 a77da128..dd1f3a17 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -44,14 +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; -} - -export enum HingeStatus { - Closed = 'closed', - PartiallyOpen = 'partiallyOpen', - FullyOpen = 'fullyOpen', + /** 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 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; } export interface VerticalControlAreaHandle extends NativeUIShellHandle { @@ -163,21 +159,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 ab3aadb9..20e41973 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -20,13 +20,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; @@ -98,8 +96,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; }; @@ -110,6 +107,39 @@ 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(); +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. * Pass `rtl` when the document direction is known; otherwise the nearest `dir` attribute is used. @@ -118,11 +148,16 @@ 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.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`); 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. */ @@ -186,31 +221,27 @@ 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; + let stopVerticalBarsLayout: (() => void) | 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; + if (!options.verticalBarsOnly) stopVerticalBarsLayout = observeNativeVerticalBarsLayout(document); + // The application owns device state and selects the rail through placement classes. 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; + const app = document.querySelector('ion-app.ios-theme-vertical-bars'); + if (!app) return false; + const reported = nativePlacements.get(app); + 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)); }; - 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; + 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, @@ -220,16 +251,16 @@ 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(() => {}); + stopVerticalBarsLayout?.(); prehide?.stop(); stopModals(); }, }); } catch (error) { await runtime?.destroy(); - await placementListener?.remove().catch(() => {}); - if (monitoring) await plugin.stopDeviceLayoutMonitoring().catch(() => {}); + await metricsListener?.remove().catch(() => {}); + stopVerticalBarsLayout?.(); return fallback(error instanceof Error ? error.message : String(error)); } })()); diff --git a/src/native/runtime.ts b/src/native/runtime.ts index 08cb79ad..f76bc75b 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -419,13 +419,14 @@ export const createRuntime = async ( verticalBarsMembers.add(element); } for (const element of accepted.flatMap(candidateSources)) { + const newlyProjected = !sources.has(element) || !element.hasAttribute(marker); if (!sources.has(element)) { sources.set(element, element.getAttribute('aria-hidden')); crossfade.play(element, true, handoffInstant || isVerticalBarsSource(element)); - element.setAttribute(marker, ''); - element.setAttribute('aria-hidden', 'true'); - element.dispatchEvent(new CustomEvent('nativeUIShellChange')); } + element.setAttribute(marker, ''); + element.setAttribute('aria-hidden', 'true'); + if (newlyProjected) element.dispatchEvent(new CustomEvent('nativeUIShellChange')); } const received = pendingActivations; pendingActivations = []; diff --git a/src/native/vertical-bars-web.ts b/src/native/vertical-bars-web.ts index 8d86327a..c8a42859 100644 --- a/src/native/vertical-bars-web.ts +++ b/src/native/vertical-bars-web.ts @@ -293,6 +293,8 @@ export const createVerticalBarsWebProjection = ( }; const schedule = (): Promise => { if (stopped) return Promise.resolve(); + // Release Web ownership before the native runtime can acknowledge its next frame. + if (!enabled()) restore(); const done = new Promise((resolve) => waiters.push(resolve)); if (!frame) frame = win.requestAnimationFrame(update); return done; 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';