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)); } })());