diff --git a/demo/e2e/native-ui-shell.spec.ts b/demo/e2e/native-ui-shell.spec.ts index ee691e4b..de9f34df 100644 --- a/demo/e2e/native-ui-shell.spec.ts +++ b/demo/e2e/native-ui-shell.spec.ts @@ -18,6 +18,7 @@ interface ShellMock extends ShellMockCore { lateSearch?: () => void; tabRetirements: number; retirementDetails: { path: string; tabs: string }[]; + preparedPopovers: number; } const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'trailing' | null | 'unreported' = 'trailing') => { @@ -46,6 +47,13 @@ const mockNative = async (page: Page, fail = false, nativeEdge: 'leading' | 'tra notifyListeners(eventName: string, data: unknown) { for (const listener of this.listeners[eventName] ?? []) listener(data as never); }, + preparedPopovers: 0, + async prepareOverlay() { + this.preparedPopovers++; + throw new Error('Native popover unavailable'); + }, + async closeOverlay() {}, + async stopOverlays() {}, async configure(options: { verticalBarsOnly?: boolean }) { this.configuredWith = options; return { supported: true, verticalBars: nativeEdge !== null }; @@ -3376,3 +3384,230 @@ test('tab visibility ignores query parameters and fragments', async ({ page }) = await page.goto('/main/index?verticalBarsOnly&buttonDefaultFill=solid#comparison'); await expect(page.locator('ion-tab-bar')).not.toHaveClass(/tab-bar-hidden/); }); + +for (const standalone of [false, true]) + test(`Vertical Bars popover retains native controls and background geometry (${standalone ? 'standalone' : 'full shell'})`, async ({ + page, + }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page); + await page.goto(`/main/index/popover${standalone ? '?verticalBarsOnly' : ''}`); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const trigger = page.locator('#native-popover-trigger'); + await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '', { timeout: 15000 }); + await page.evaluate(() => + Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), { + async prepareOverlay() {}, + async presentOverlay() {}, + async dismissOverlay() {}, + }), + ); + await page + .locator('app-popover ion-content') + .first() + .evaluate(async (content: HTMLIonContentElement) => content.scrollToPoint(0, 100)); + const geometry = () => + page + .locator('app-popover ion-content') + .first() + .evaluate(async (content: HTMLIonContentElement) => { + const rect = content.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, scroll: (await content.getScrollElement()).scrollTop }; + }); + const before = await geometry(); + for (let attempt = 1; attempt <= 3; attempt++) { + const updateCount = await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').updates.length); + const opened = page.waitForEvent('popup'); + await activate(page, 'Open popover'); + const relay = await opened; + await expect(relay.getByText('Toolbar popover', { exact: true })).toBeVisible(); + expect(await relay.evaluate(() => document.compatMode)).toBe('CSS1Compat'); + expect((await relay.getByRole('button', { name: 'Increment', exact: true }).boundingBox())!.height).toBeLessThan(80); + await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '', { timeout: 15000 }); + expect(await geometry()).toEqual(before); + expect( + await page.evaluate( + (start) => + Capacitor.registerPlugin('IonicNativeUIShell') + .updates.slice(start) + .every((snapshot) => + snapshot.controls.some( + (control) => + control.placement === 'vertical-bars' && control.items.some((item) => item.accessibilityLabel === 'Open popover'), + ), + ), + updateCount, + ), + ).toBe(true); + await relay.getByRole('button', { name: 'Increment', exact: true }).click(); + await expect(relay.getByText(`Count: ${attempt}`, { exact: true })).toBeVisible(); + await relay.getByRole('button', { name: 'Close popover', exact: true }).click(); + await expect.poll(() => relay.isClosed()).toBe(true); + await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '', { timeout: 15000 }); + expect(await geometry()).toEqual(before); + } + }); + +test('ordinary popovers stay in the source WebView', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/popover'); + await expect(page.locator('#native-popover-trigger')).toHaveAttribute('data-native-ui-shell', ''); + + await activate(page, 'Open popover'); + await expect(page.getByText('Toolbar popover', { exact: true })).toBeVisible(); + expect(page.context().pages()).toHaveLength(1); + expect(await page.evaluate(() => Capacitor.registerPlugin('IonicNativeUIShell').preparedPopovers)).toBe(0); + await page.getByRole('button', { name: 'Close popover', exact: true }).click(); +}); + +test('Vertical Bars popover preparation failure restores usable Web content and projection updates', async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page); + await page.goto('/main/index/popover'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const trigger = page.locator('#native-popover-trigger'); + await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '', { timeout: 15000 }); + await activate(page, 'Open popover'); + await expect(page.getByText('Toolbar popover', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Increment', exact: true }).click(); + await expect(page.getByText('Count: 1', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Close popover', exact: true }).click(); + await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '', { timeout: 15000 }); + expect(page.context().pages()).toHaveLength(1); +}); + +for (const disabled of [false, true]) + test(`Vertical Bars popover handles anchor clicks during dismissal (${disabled ? 'disabled before replay' : 'reopens once'})`, async ({ + page, + }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page); + await page.goto('/main/index/popover'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const trigger = page.locator('#native-popover-trigger'); + await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '', { timeout: 15000 }); + await page.evaluate(() => { + const plugin = Capacitor.registerPlugin void }>('IonicNativeUIShell'); + Object.assign(plugin, { + async prepareOverlay() {}, + async presentOverlay() {}, + dismissOverlay: () => + new Promise((resolve) => { + plugin.finishDismiss = resolve; + }), + }); + }); + const opened = page.waitForEvent('popup'); + await activate(page, 'Open popover'); + const relay = await opened; + await expect(relay.getByText('Toolbar popover', { exact: true })).toBeVisible(); + await relay.getByRole('button', { name: 'Close popover', exact: true }).click(); + await expect + .poll(() => + page.evaluate(() => !!Capacitor.registerPlugin void }>('IonicNativeUIShell').finishDismiss), + ) + .toBe(true); + // UIKit can accept the next tap while Ionic still waits for didDismiss. + // Repeated clicks must coalesce, and a now-disabled source must not replay. + for (let i = 0; i < 3; i++) await activate(page, 'Open popover'); + if (disabled) + await trigger.evaluate((button: HTMLIonButtonElement) => { + button.disabled = true; + }); + await page.evaluate(() => Capacitor.registerPlugin void }>('IonicNativeUIShell').finishDismiss!()); + await expect.poll(() => relay.isClosed()).toBe(true); + if (disabled) { + await expect(trigger).toHaveJSProperty('disabled', true); + await expect.poll(() => page.context().pages().length).toBe(1); + } else { + await expect.poll(() => page.context().pages().length).toBe(2); + const reopened = page + .context() + .pages() + .find((candidate) => candidate !== page)!; + await expect(reopened.getByText('Toolbar popover', { exact: true })).toBeVisible(); + await reopened.getByRole('button', { name: 'Increment', exact: true }).click(); + await expect(reopened.getByText('Count: 1', { exact: true })).toBeVisible(); + } + }); + +for (const failure of ['reject', 'timeout'] as const) + test(`Vertical Bars popover preserves didDismiss after native dismissal ${failure}`, async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page); + await page.goto('/main/index/popover'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const trigger = page.locator('#native-popover-trigger'); + await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', '', { timeout: 15000 }); + await page.evaluate((failure) => { + Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), { + async prepareOverlay() {}, + async presentOverlay() {}, + dismissOverlay: () => (failure === 'reject' ? Promise.reject(new Error('Native dismiss failed')) : new Promise(() => {})), + }); + }, failure); + const opened = page.waitForEvent('popup'); + await activate(page, 'Open popover'); + const relay = await opened; + await expect(relay.getByText('Toolbar popover', { exact: true })).toBeVisible(); + const result = await page.locator('ion-popover[trigger="native-popover-trigger"]').evaluate(async (overlay: HTMLIonPopoverElement) => { + const didDismiss = overlay.onDidDismiss(); + await overlay.dismiss({ saved: true }, 'confirm'); + return Promise.race([didDismiss, new Promise((resolve) => setTimeout(() => resolve('missing didDismiss'), 1000))]); + }); + expect(result).toEqual({ data: { saved: true }, role: 'confirm' }); + await expect.poll(() => relay.isClosed()).toBe(true); + await expect(trigger).toHaveAttribute('data-native-ui-shell-vertical-bars-member', ''); + const reopened = page.waitForEvent('popup'); + await activate(page, 'Open popover'); + await expect((await reopened).getByText('Toolbar popover', { exact: true })).toBeVisible(); + }); + +for (const nestedInRelay of [false, true]) + test(`Vertical Bars popover restores nested overlays (${nestedInRelay ? 'inline content' : 'source controller'})`, async ({ page }) => { + await page.setViewportSize({ width: 700, height: 900 }); + await mockNative(page); + await page.goto('/main/index/popover'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + await expect(page.locator('#native-popover-trigger')).toHaveAttribute('data-native-ui-shell-vertical-bars-member', ''); + await page.evaluate(() => + Object.assign(Capacitor.registerPlugin('IonicNativeUIShell'), { + async prepareOverlay() {}, + async presentOverlay() {}, + async dismissOverlay() {}, + }), + ); + await page.evaluate(() => { + const open = window.open.bind(window); + window.open = (...args) => { + const relay = open(...args); + (window as Window & { reviewRelay?: Window | null }).reviewRelay = relay; + return relay; + }; + }); + const opened = page.waitForEvent('popup'); + await activate(page, 'Open popover'); + const relay = await opened; + await expect(relay.getByText('Toolbar popover', { exact: true })).toBeVisible(); + await page.evaluate(async (nestedInRelay) => { + const nested = document.createElement('ion-popover'); + nested.id = 'nested-review-popover'; + const body = document.createElement('div'); + body.textContent = 'Nested content'; + nested.component = body; + if (nestedInRelay) { + // The Angular content remains live in the child document. + const windows = window as Window & { reviewRelay?: Window }; + windows.reviewRelay!.document.querySelector('ion-popover > div')!.append(nested); + } else document.querySelector('ion-app')!.append(nested); + await nested.present(); + }, nestedInRelay); + await expect.poll(() => relay.isClosed()).toBe(true); + await expect(page.getByText('Nested content', { exact: true })).toBeVisible(); + await page + .locator('#nested-review-popover') + .evaluate(async (overlay: HTMLIonPopoverElement) => overlay.dismiss(undefined, undefined, false)); + await expect(page.getByText('Toolbar popover', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Close popover', exact: true }).click(); + await expect(page.locator('#native-popover-trigger')).toHaveAttribute('data-native-ui-shell-vertical-bars-member', ''); + }); diff --git a/demo/e2e/screenshot.spec.ts-snapshots/popover-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/popover-dark.png index 9ece9087..034bcdc1 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/popover-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/popover-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/popover.png b/demo/e2e/screenshot.spec.ts-snapshots/popover.png index ea5d8c73..6d31302b 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/popover.png and b/demo/e2e/screenshot.spec.ts-snapshots/popover.png differ diff --git a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift index f9c14590..86cb29c4 100644 --- a/demo/ios/NativeUIShellTests/NativeUIShellTests.swift +++ b/demo/ios/NativeUIShellTests/NativeUIShellTests.swift @@ -504,6 +504,50 @@ final class NativeUIShellTests: XCTestCase { XCTAssertTrue(main.identifier.hasPrefix("shell-")) } + func testVerticalBarsPopoverKeepsBackgroundAndLiveActions() throws { + let app = XCUIApplication(bundleIdentifier: "io.ionic.theme.ios27") + app.launch() + let toggle = app.webViews.switches["iPhone Duo Mode"] + XCTAssertTrue(toggle.waitForExistence(timeout: 15), app.debugDescription) + if toggle.value as? String == "0" { toggle.tap() } + openPage(app, name: "popover") + let trigger = nativeButton(app, label: "Open popover") + XCTAssertTrue(trigger.waitForExistence(timeout: 10), app.debugDescription) + assertOnVerticalBars(trigger, in: app) + let source = app.webViews.firstMatch + let sourceFrame = source.frame + let triggerFrame = trigger.frame + let background = app.webViews.staticTexts["Controller"].firstMatch + XCTAssertTrue(background.exists, app.debugDescription) + let backgroundFrame = background.frame + capture("vertical-popover-before") + for attempt in 1...20 { + trigger.tap() + let title = app.webViews.staticTexts["Toolbar popover"].firstMatch + XCTAssertTrue(title.waitForExistence(timeout: 5), app.debugDescription) + // SwiftUI's modal accessibility scope hides covered controls from XCTest. + // Their native identities and geometry are checked again after dismissal. + capture("vertical-popover-open-\(attempt)") + let increment = app.webViews.buttons["Increment"].firstMatch + XCTAssertLessThan(increment.frame.height, 80, "Popover buttons must keep their intrinsic height") + increment.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + XCTAssertTrue(app.webViews.staticTexts["Count: \(attempt)"].firstMatch.waitForExistence(timeout: 5), app.debugDescription) + if attempt % 2 == 0 { + app.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.8)).tap() + } else { + app.webViews.buttons["Close popover"].firstMatch.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + XCTAssertTrue(title.waitForNonExistence(timeout: 5), app.debugDescription) + XCTAssertTrue(trigger.waitForExistence(timeout: 5), app.debugDescription) + XCTAssertEqual(trigger.frame.minX, triggerFrame.minX, accuracy: 1) + XCTAssertEqual(trigger.frame.minY, triggerFrame.minY, accuracy: 1) + XCTAssertEqual(source.frame.minX, sourceFrame.minX, accuracy: 1) + XCTAssertEqual(source.frame.minY, sourceFrame.minY, accuracy: 1) + XCTAssertEqual(background.frame.minY, backgroundFrame.minY, accuracy: 1) + capture("vertical-popover-dismissed-\(attempt)") + } + } + private func openPage(_ app: XCUIApplication, name: String) { let label = name == "native-ui-shell" ? "native-ui-shell (Preview)" : name let entry = app.webViews.buttons[label].exists ? app.webViews.buttons[label] : app.webViews.links[label] diff --git a/demo/src/app/index/pages/popover/popover-content.component.ts b/demo/src/app/index/pages/popover/popover-content.component.ts new file mode 100644 index 00000000..8d0dfc8d --- /dev/null +++ b/demo/src/app/index/pages/popover/popover-content.component.ts @@ -0,0 +1,34 @@ +import { Component, input, signal } from '@angular/core'; +import { IonButton, IonContent, IonItem, IonItemGroup, IonLabel, IonList } from '@demo/ionic'; + +@Component({ + selector: 'app-popover-content', + standalone: true, + imports: [IonButton, IonContent, IonItem, IonItemGroup, IonLabel, IonList], + template: ` + + + + Count: {{ count() }}Increment + @if (scrollable()) { + @for (row of rows; track row) { + Row {{ row }} + } + } + + + + `, + styles: [':host { display: block; height: 100%; }'], +}) +export class PopoverContentComponent { + readonly scrollable = input(false); + readonly count = signal(0); + readonly rows = Array.from({ length: 20 }, (_, index) => index + 1); + readonly increment = (value: number) => value + 1; +} diff --git a/demo/src/app/index/pages/popover/popover.page.html b/demo/src/app/index/pages/popover/popover.page.html index 5b04510b..a671a08b 100644 --- a/demo/src/app/index/pages/popover/popover.page.html +++ b/demo/src/app/index/pages/popover/popover.page.html @@ -4,8 +4,23 @@ popover + + + + + + +
+

Toolbar popover

+

Count: {{ toolbarCount() }}

+ Increment + Close popover +
+
@@ -14,15 +29,20 @@

popover

- - - This page is a component demo for @rdlabo/ionic-theme-ios27.
+ + Controller + + Compact controller popover + Scrollable controller popover + + + diff --git a/demo/src/app/index/pages/popover/popover.page.ts b/demo/src/app/index/pages/popover/popover.page.ts index 29f06c48..bd88157e 100644 --- a/demo/src/app/index/pages/popover/popover.page.ts +++ b/demo/src/app/index/pages/popover/popover.page.ts @@ -1,4 +1,6 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, inject, OnInit, signal } from '@angular/core'; + +import { PopoverContentComponent } from './popover-content.component'; import { FormsModule } from '@angular/forms'; import { @@ -18,6 +20,7 @@ import { IonText, IonTitle, IonToolbar, + PopoverController, } from '@demo/ionic'; @Component({ @@ -46,7 +49,21 @@ import { ], }) export class PopoverPage implements OnInit { - constructor() {} + readonly toolbarCount = signal(0); + incrementToolbar() { + this.toolbarCount.update((value) => value + 1); + } + readonly popoverController = inject(PopoverController); + + async present(event: Event, scrollable = false) { + const popover = await this.popoverController.create({ + component: PopoverContentComponent, + componentProps: { scrollable }, + event, + cssClass: scrollable ? 'demo-scrollable-popover' : 'demo-compact-popover', + }); + await popover.present(); + } ngOnInit() {} } diff --git a/demo/src/global.scss b/demo/src/global.scss index fba628bb..82046d09 100644 --- a/demo/src/global.scss +++ b/demo/src/global.scss @@ -228,3 +228,12 @@ app-settings-page { } } } + +ion-popover.demo-compact-popover { + --width: 280px; + --height: 90px; +} +ion-popover.demo-scrollable-popover { + --width: 300px; + --height: 360px; +} diff --git a/demo/src/main.ts b/demo/src/main.ts index 23dc4c40..4680659d 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -45,7 +45,11 @@ const startShell = stockIonic || new URLSearchParams(window.location.search).has('verticalBarsOnly') ? enableVerticalControlArea : enableNativeUIShell; const buttonDefaultFill = stockIonic || new URLSearchParams(window.location.search).get('buttonDefaultFill') === 'solid' ? 'solid' : null; const buttonProjection = new URLSearchParams(window.location.search).get('buttonProjection') === 'source' ? 'source' : 'system'; -void startShell({ buttonProjection, buttonDefaultFill }).then((handle) => { +void startShell({ + buttonProjection, + buttonDefaultFill, + controls: { tabs: true, toolbar: true, segment: true, fab: true, popover: true }, +}).then((handle) => { const app = document.querySelector('ion-app'); if (app) Object.assign(app, { nativeUIShell: handle }); }); diff --git a/demo/src/native-ui-shell-lifecycle.spec.ts b/demo/src/native-ui-shell-lifecycle.spec.ts index 8d5960e6..8ad545b1 100644 --- a/demo/src/native-ui-shell-lifecycle.spec.ts +++ b/demo/src/native-ui-shell-lifecycle.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { enableNativeUIShell, setVerticalControlAreaPlacement } from '../../src/native'; +import { enableNativeUIShell, enableVerticalControlArea, setVerticalControlAreaPlacement } from '../../src/native'; test('placement requires ion-app and clears it when disabled', () => { document.body.replaceChildren(); @@ -51,3 +51,12 @@ test('a new activation survives the previous runtime finishing its destroy', asy await enableNativeUIShell({ enabled: false }); expect(second.getStatus().state).toBe('stopped'); }); + +test('standalone Vertical Bars forwards popover opt-in into the shared runtime', async () => { + const handle = await enableVerticalControlArea({ controls: { popover: true } }); + await expect( + enableNativeUIShell({ controls: { tabs: true, toolbar: true, popover: true }, verticalBarsOnly: true }), + ).resolves.toBeDefined(); + await expect(enableVerticalControlArea()).rejects.toThrow('different controls'); + await handle.destroy(); +}); diff --git a/docs/native-ui-shell.md b/docs/native-ui-shell.md index 25e1c0ca..6063a4f4 100644 --- a/docs/native-ui-shell.md +++ b/docs/native-ui-shell.md @@ -126,7 +126,7 @@ The built-in `iosTransitionAnimation` waits for native retirement before startin Tab switches skip the Web/native crossfade so a retiring UIKit snapshot cannot linger over the next tab. Detection uses the router URL versus the still-selected tab on `ionViewWillLeave` (and vanilla `ionTabsWillChange` / `ionTabsDidChange` DOM events). Stack pushes and pops keep the normal 180ms handoff. -Standard Ionic overlays suspend native projection until dismissal. Unsupported searchable-tab configurations use the existing Web animation and share this ownership with Web glass gestures. CSS motion of supported containing surfaces also causes temporary Web rendering. +Standard Ionic overlays suspend native projection until dismissal, except for the opt-in native Vertical Bars popovers described below. Unsupported searchable-tab configurations use the existing Web animation and share this ownership with Web glass gestures. CSS motion of supported containing surfaces also causes temporary Web rendering. During retirement, the source is restored and allowed to paint before its native cover is removed. During acquisition, the source is hidden only after a successful, current native response. Delayed responses are revalidated per control: existing eligible controls retain their native cover while content updates catch up. Only removed or ineligible sources return to Web; newly acquired sources require an exact acknowledgement. Ordinary page mutations never call the global clear operation. UIKit tab instances and items are retained, and equal frames/selections are not reapplied. Duplicate/stale activations are discarded. WebKit and UIKit still render separately: the implementation avoids an intentional blank frame, but does not provide an OS-level atomic compositing guarantee. Validate custom transitions and overlays on the app's supported simulators before rollout; unknown overlay systems are outside the automatic integration contract. @@ -154,7 +154,23 @@ const shell = await enableNativeUIShell({ const disabledShell = await enableNativeUIShell({ enabled: false }); ``` -Omitting `controls` enables every supported control for backward compatibility. When `controls` is present, only entries set to `true` are native-eligible. Available entries are `tabs`, `toolbar`, `segment`, and `fab`. +Omitting `controls` enables `tabs`, `toolbar`, `segment`, and `fab` for backward compatibility. When `controls` is present, only entries set to `true` are native-eligible. `popover` is opt-in and defaults to `false`. + +### Popovers anchored to Vertical Bars + +Enable `controls.popover` together with `controls.toolbar` to present popovers from accepted native Vertical Bars `ion-button` controls: + +```ts +await enableNativeUIShell({ + controls: { tabs: true, toolbar: true, segment: true, fab: true, popover: true }, +}); +``` + +The same option is available through `enableVerticalControlArea()`. Use an `ion-popover` whose `trigger` identifies the projected button, or pass that button's click event to `PopoverController.create({ event, ... })`. The button must be accepted by native Vertical Bars; regular toolbar buttons, body controls, and missing or rejected anchors retain Ionic's Web presentation. + +The page's native controls stay projected throughout preparation, presentation and dismissal. If the same native anchor accepts a tap while Ionic is still dismissing, one activation is retained and revalidated after cleanup; repeated taps coalesce. The relay prepares its child WebView while Ionic initializes the content, bypasses the Web enter animation, and presents through the button's standard SwiftUI popover. It does not resize or reparent the source WebView. Ionic retains its controller, lifecycle, live application handlers and dismissal data; the live content temporarily moves into the child document and is restored during cleanup. + +Only one popover is relayed at a time. Opening another overlay or menu, leaving the page, suspending the shell, or losing the native layout restores Web rendering. Modal, sheet and alert relay are outside this feature. Native presentation owns the arrow, corners and animation; custom Web enter/leave animations do not replace them. Initial content dimensions come from Ionic; arbitrary cross-document CSS, document-bound queries and full VoiceOver/hardware-keyboard parity are not guaranteed. Failure to prepare or find the native anchor restores the Web popover. For a custom modal or overlay that Native UI Shell cannot detect, acquire a suspension before presenting it. The resolved suspension means projected controls have returned to Web rendering. Always release it after dismissal: diff --git a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift index c5e76e9b..cc13f1de 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/Components/ShellVerticalBars.swift @@ -7,6 +7,7 @@ protocol ShellVerticalBarsControlling: AnyObject { func attach(to owner: UIViewController, in parent: UIView) @MainActor func apply(_ controls: [ShellControl], rendering: ShellRendering, edge: String) var ownsKeyboardChrome: Bool { get } + func presentPopover(anchor: String, host: ShellOverlayHost, completion: @escaping (Bool) -> Void) -> Bool func detach() } @@ -32,6 +33,9 @@ final class ShellVerticalBarsModel: ObservableObject { let slot: ShellControl.ToolbarSlot? } + @Published var popoverAnchor: String? + var popoverHost: ShellOverlayHost? + var popoverPresented: ((Bool) -> Void)? @Published var search: ShellVerticalSearchModel? @Published var back: Item? @Published var groups: [Group] = [] @@ -129,6 +133,13 @@ private func verticalBarsButton(_ item: ShellVerticalBarsModel.Item, model: Shel } } +@available(iOS 26.0, *) +private struct ShellVerticalPopoverContent: UIViewControllerRepresentable { + let host: ShellOverlayHost + func makeUIViewController(context: Context) -> ShellOverlayHost { host } + func updateUIViewController(_ controller: ShellOverlayHost, context: Context) {} +} + @available(iOS 26.0, *) private struct ShellVerticalBarsButton: ToolbarContent { @ObservedObject var model: ShellVerticalBarsModel @@ -138,7 +149,40 @@ private struct ShellVerticalBarsButton: ToolbarContent { // Observe here so same-ID updates also refresh the shared toolbar background. var body: some ToolbarContent { if let item = model.groups.lazy.flatMap(\.items).first(where: { $0.id == id }) { - ToolbarItem(placement: placement) { verticalBarsButton(item, model: model) } + ToolbarItem(placement: placement) { + verticalBarsButton(item, model: model) + .popover(isPresented: Binding( + get: { model.popoverAnchor == item.id }, + set: { shown in + if !shown && model.popoverAnchor == item.id { + model.popoverAnchor = nil + model.popoverHost?.event("dismiss") + } + } + )) { + if let host = model.popoverHost { + ShellVerticalPopoverContent(host: host) + .frame(width: host.preferredContentSize.width, height: host.preferredContentSize.height) + .preferredColorScheme(host.options?["dark"] as? Bool == true ? .dark : .light) + .presentationCompactAdaptation(.popover) + .interactiveDismissDisabled(host.options?["backdropDismiss"] as? Bool == false) + .onAppear { + guard model.popoverHost === host else { return } + host.anchoredVisible = true + let completion = model.popoverPresented + model.popoverPresented = nil + completion?(true) + } + .onDisappear { + host.anchoredVisible = false + let completion = host.anchoredDidDismiss + host.anchoredDidDismiss = nil + if model.popoverHost === host { model.popoverHost = nil } + completion?() + } + } + } + } .sharedBackgroundVisibility(item.clear ? .hidden : .automatic) } } @@ -426,6 +470,36 @@ final class ShellVerticalBarsController: ShellVerticalBarsControlling { controller.overrideUserInterfaceStyle = controls.contains(where: \.dark) ? .dark : .light } + func presentPopover(anchor: String, host: ShellOverlayHost, completion: @escaping (Bool) -> Void) -> Bool { + guard container.window != nil, model.popoverAnchor == nil, + model.groups.flatMap(\.items).contains(where: { $0.id == anchor && !$0.disabled }) else { return false } + model.popoverHost = host + model.popoverPresented = completion + host.anchoredVisible = false + host.dismissAnchored = { [weak self, weak host] in + guard let self, let host, self.model.popoverHost === host else { return } + // A backdrop tap clears the binding before SwiftUI finishes closing. + // Keep the live content and completion until onDisappear, including + // when dismissal has already started through the system binding. + if self.model.popoverAnchor != nil { + var transaction = Transaction() + transaction.disablesAnimations = host.options?["animated"] as? Bool == false + withTransaction(transaction) { self.model.popoverAnchor = nil } + } + // Before onAppear there will be no onDisappear to finish cleanup. + if !host.anchoredVisible { + let completion = self.model.popoverPresented + self.model.popoverPresented = nil + self.model.popoverHost = nil + completion?(false) + } + } + var transaction = Transaction() + transaction.disablesAnimations = host.options?["animated"] as? Bool == false + withTransaction(transaction) { model.popoverAnchor = anchor } + return true + } + func detach() { guard controller.parent != nil else { return } controller.willMove(toParent: nil) diff --git a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift index 0bac7c88..4751a4a9 100644 --- a/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +++ b/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift @@ -10,8 +10,14 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getWebViewMetrics", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "update", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "clear", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "prepareOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "presentOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "dismissOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "closeOverlay", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "stopOverlays", returnType: CAPPluginReturnPromise) ] + private var overlays: ShellOverlayController? private var host: ShellHost? private var verticalBars: ShellVerticalBarsControlling? private var controls: [String: UIView] = [:] @@ -74,6 +80,56 @@ public class IonicNativeUIShellPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarDele observers.forEach(NotificationCenter.default.removeObserver) } + @objc func prepareOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let self, let id = call.getString("id"), + let source = self.bridge?.webView, let owner = self.bridge?.viewController else { + call.reject("Popover source unavailable"); return + } + if self.overlays == nil { + self.overlays = ShellOverlayController(source: source, owner: owner) { [weak self] id, action in + self?.notifyListeners("overlay", data: ["id": id, "action": action]) + } + } + guard self.overlays?.prepare(id) == true else { call.reject("Popover already pending"); return } + call.resolve() + } + } + + @objc func presentOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let self, let overlays = self.overlays, let id = call.getString("id") else { + call.reject("Popover not prepared"); return + } + overlays.present(id, options: call.getObject("presentation"), anchored: { [weak self] anchor, host, completion in + self?.verticalBars?.presentPopover(anchor: anchor, host: host, completion: completion) ?? false + }) { presented in + if presented { call.resolve() } else { call.reject("Native Vertical Bars anchor unavailable") } + } + } + } + + @objc func dismissOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return } + overlays.dismiss(id, animated: call.getBool("animated") ?? false) { call.resolve() } + } + } + + @objc func closeOverlay(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let overlays = self?.overlays, let id = call.getString("id") else { call.resolve(); return } + overlays.close(id) { call.resolve() } + } + } + + @objc func stopOverlays(_ call: CAPPluginCall) { + DispatchQueue.main.async { [weak self] in + guard let self, let overlays = self.overlays else { call.resolve(); return } + overlays.stop { self.overlays = nil; call.resolve() } + } + } + private func webViewMetrics() -> JSObject? { guard #available(iOS 26.0, *), let webView = bridge?.webView else { return nil } webView.layoutIfNeeded() diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift new file mode 100644 index 00000000..ca42ac7b --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayController.swift @@ -0,0 +1,116 @@ +import UIKit +import WebKit + +/// Intercepts only a prepared blank window. Other requests keep Capacitor's behavior. +final class ShellOverlayController: NSObject, WKUIDelegate { + private weak var source: WKWebView? + private weak var owner: UIViewController? + private let original: WKUIDelegate? + private let automaticallyOpensWindows: Bool + private var prepared: String? + private let event: (String, String) -> Void + private var id: String? + private var host: ShellOverlayHost? + + init(source: WKWebView, owner: UIViewController, event: @escaping (String, String) -> Void) { + self.source = source + self.owner = owner + self.event = event + original = source.uiDelegate + automaticallyOpensWindows = source.configuration.preferences.javaScriptCanOpenWindowsAutomatically + super.init() + source.uiDelegate = self + } + + override func responds(to selector: Selector!) -> Bool { + super.responds(to: selector) || original?.responds(to: selector) == true + } + + override func forwardingTarget(for selector: Selector!) -> Any? { + original?.responds(to: selector) == true ? original : super.forwardingTarget(for: selector) + } + + func prepare(_ id: String) -> Bool { + guard prepared == nil, host == nil else { return false } + prepared = id + source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + return true + } + + func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { + // WebKit percent-encodes '#' inside about: URLs; compare the decoded form. + guard webView === source, navigationAction.sourceFrame.isMainFrame, + let prepared, navigationAction.targetFrame == nil, + navigationAction.request.url?.absoluteString.removingPercentEncoding == "about:blank#\(prepared)" else { + return original?.webView?(webView, createWebViewWith: configuration, + for: navigationAction, windowFeatures: windowFeatures) + } + self.prepared = nil + source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows + id = prepared + let host = ShellOverlayHost(configuration: configuration, options: nil) { [weak self] action in + self?.event(prepared, action) + } + host.webView.uiDelegate = self + host.webView.frame = source?.bounds ?? .zero + self.host = host + return host.webView + } + + func present(_ id: String, options: [String: Any]?, anchored: (String, ShellOverlayHost, @escaping (Bool) -> Void) -> Bool, + completion: @escaping (Bool) -> Void) { + guard self.id == id, let host, let owner, + owner.presentedViewController == nil else { completion(false); return } + host.apply(options) + if host.options?["kind"] as? String == "popover", + let anchor = host.options?["anchorId"] as? String, + anchored(anchor, host, completion) { return } + completion(false) + } + + // Keep the WebView alive until JavaScript has restored the adopted nodes and listeners. + func dismiss(_ id: String, animated: Bool, completion: @escaping () -> Void) { + guard self.id == id, let host else { completion(); return } + if let dismiss = host.dismissAnchored { + if host.anchoredVisible { host.anchoredDidDismiss = completion; dismiss() } + else { dismiss(); completion() } + return + } + guard host.presentingViewController != nil else { completion(); return } + host.dismiss(animated: animated, completion: completion) + } + + func close(_ id: String, completion: @escaping () -> Void) { + if prepared == id { + prepared = nil + source?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows + } + guard self.id == id, let host else { completion(); return } + let finish = { [self] in + host.webView.uiDelegate = nil + self.host = nil + self.id = nil + completion() + } + if let dismiss = host.dismissAnchored { + if host.anchoredVisible { host.anchoredDidDismiss = finish; dismiss() } + else { dismiss(); finish() } + } + else if host.presentingViewController == nil { finish() } + else { host.dismiss(animated: false, completion: finish) } + } + + func stop(completion: @escaping () -> Void) { + let finish = { [self] in + prepared = nil + if let source, source.uiDelegate === self { + source.uiDelegate = original + source.configuration.preferences.javaScriptCanOpenWindowsAutomatically = automaticallyOpensWindows + } + completion() + } + if let id { close(id, completion: finish) } + else { finish() } + } +} diff --git a/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift new file mode 100644 index 00000000..a05ecb21 --- /dev/null +++ b/ios/Sources/IonicNativeUIShellPlugin/Overlays/ShellOverlayHost.swift @@ -0,0 +1,36 @@ +import UIKit +import WebKit + +/// Hosts only the live content of a Vertical Bars popover. +final class ShellOverlayHost: UIViewController { + let webView: WKWebView + private(set) var options: [String: Any]? + let event: (String) -> Void + var dismissAnchored: (() -> Void)? + var anchoredVisible = false + var anchoredDidDismiss: (() -> Void)? + + init(configuration: WKWebViewConfiguration, options: [String: Any]?, event: @escaping (String) -> Void) { + webView = WKWebView(frame: .zero, configuration: configuration) + self.event = event + super.init(nibName: nil, bundle: nil) + apply(options) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func apply(_ options: [String: Any]?) { + guard let options else { return } + self.options = options + preferredContentSize = CGSize(width: options["width"] as? Double ?? 280, height: options["height"] as? Double ?? 200) + overrideUserInterfaceStyle = options["dark"] as? Bool == true ? .dark : .light + } + + override func loadView() { + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + webView.scrollView.contentInsetAdjustmentBehavior = .never + view = webView + view.accessibilityViewIsModal = true + } +} diff --git a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift index 13fcf74e..4c303a9f 100644 --- a/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +++ b/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift @@ -1,8 +1,35 @@ import XCTest +import WebKit import Capacitor @testable import IonicNativeUIShellPlugin final class ShellSnapshotTests: XCTestCase { + @MainActor func testVerticalPopoverCanCancelBeforeSwiftUIAppearsAndReopen() throws { + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native Vertical Bars") } + let owner = UIViewController() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 700, height: 900)) + window.rootViewController = owner + window.makeKeyAndVisible() + defer { window.isHidden = true } + let rail = ShellVerticalBarsController(activate: { _ in }, changed: { _, _, _, _, _ in 0 }) + rail.attach(to: owner, in: owner.view) + defer { rail.detach() } + rail.apply(try decode([control(["placement": "vertical-bars"])]).controls, + rendering: ShellRendering(), edge: "right") + for _ in 0..<3 { + let host = ShellOverlayHost(configuration: WKWebViewConfiguration(), options: nil, event: { _ in }) + var results: [Bool] = [] + XCTAssertTrue(rail.presentPopover(anchor: "action", host: host, completion: { results.append($0) })) + // Cancel in the same runloop turn, before SwiftUI can call onAppear. + XCTAssertFalse(host.anchoredVisible) + host.dismissAnchored?() + XCTAssertEqual(results, [false]) + XCTAssertFalse(host.anchoredVisible) + host.dismissAnchored?() + XCTAssertEqual(results, [false], "Cancellation must settle presentation only once") + } + } + private func item(_ changes: JSObject = [:]) -> JSObject { var value: JSObject = ["id": "action", "x": 4.0, "y": 8.0, "width": 44.0, "height": 44.0, "label": "Send", "accessibilityLabel": "Send message", "disabled": false, "selected": false, diff --git a/src/native/definitions.ts b/src/native/definitions.ts index f36e5601..ea639cbb 100644 --- a/src/native/definitions.ts +++ b/src/native/definitions.ts @@ -11,6 +11,8 @@ export interface NativeUIShellStatus { } export interface VerticalControlAreaOptions { + /** Opt-in popovers anchored to native Vertical Bars buttons. */ + controls?: Pick; /** Appearance of native vertical ion-button and ion-menu-button actions. * `system` (default) uses SwiftUI styling and template icons; `source` projects supported Ionic fill and colors. * Local data-projection="source|system" or ios-theme-projection-source / ios-theme-projection-system take precedence: @@ -32,7 +34,7 @@ export interface VerticalControlAreaOptions { export interface NativeUIShellOptions extends VerticalControlAreaOptions { /** Enables Native UI Shell globally. Defaults to `true`. */ enabled?: boolean; - /** Controls eligible for native projection. Omit to enable every control; when present, only `true` controls are enabled. */ + /** Controls eligible for native projection. Omit to enable tabs, toolbar, segment and fab; popover always requires explicit opt-in. When present, only `true` controls are enabled. */ controls?: NativeUIShellControls; /** Internal: limit native projection to the Vertical Control Area. */ verticalBarsOnly?: boolean; @@ -47,6 +49,8 @@ export interface NativeUIShellControls { segment?: boolean; /** Projects floating action buttons. */ fab?: boolean; + /** Relays popovers anchored to native Vertical Bars buttons. Defaults to false. */ + popover?: boolean; } export interface NativeUIShellHandle { @@ -183,9 +187,31 @@ export interface WebViewMetrics { export interface NativeUIShellPlugin { configure(options?: { verticalBarsOnly?: boolean }): Promise<{ supported: boolean }>; getWebViewMetrics(): Promise; + prepareOverlay(options: { id: string }): Promise; + presentOverlay(options: { id: string; presentation?: ShellPopoverPresentation }): Promise; + dismissOverlay(options: { id: string; animated: boolean }): Promise; + closeOverlay(options: { id: string }): Promise; + stopOverlays(): 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: 'webViewMetricsChange', listener: (event: WebViewMetrics) => void): Promise; + addListener(name: 'overlay', listener: (event: ShellOverlayEvent) => void): Promise; +} + +export interface ShellPopoverPresentation { + kind: 'popover'; + animated: boolean; + dark: boolean; + anchorId: string; + width: number; + height: number; + backgroundColor: string; + backdropDismiss: boolean; +} + +export interface ShellOverlayEvent { + id: string; + action: 'dismiss'; } diff --git a/src/native/index.ts b/src/native/index.ts index e9059deb..77424e6f 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -11,6 +11,7 @@ import type { WebViewMetrics, } from './definitions'; import { createRuntime } from './runtime'; +import { createOverlayController } from './overlays/controller'; import { createVerticalBarsWebProjection } from './vertical-bars-web'; import { prehideVerticalBarsToolbarSources } from './prehide'; import { observeVerticalBarsModals } from './shared/modal'; @@ -64,7 +65,7 @@ const manage = ( handle: NativeUIShellHandle, lifecycle: { reason?: string; - suspend?: () => (() => void) | undefined; + suspend?: () => (() => void) | undefined | Promise<(() => void) | undefined>; /** Runs after teardown; clears the shared slot only while this activation owns it. */ release?: () => void; destroy?: () => void | Promise; @@ -75,7 +76,7 @@ const manage = ( getStatus: () => (lifecycle.reason ? { ...handle.getStatus(), reason: lifecycle.reason } : handle.getStatus()), async suspend() { const lease = await handle.suspend(); - const resume = lifecycle.suspend?.(); + const resume = await lifecycle.suspend?.(); return { async resume() { await lease.resume(); @@ -89,8 +90,11 @@ const manage = ( try { await handle.destroy(); } finally { - await lifecycle.destroy?.(); - lifecycle.release?.(); + try { + await lifecycle.destroy?.(); + } finally { + lifecycle.release?.(); + } } }, }; @@ -171,7 +175,7 @@ export const enableVerticalControlArea = async (options: VerticalControlAreaOpti const handle = await enableNativeUIShell({ buttonProjection: options.buttonProjection, buttonDefaultFill: options.buttonDefaultFill, - controls: { tabs: true, toolbar: true }, + controls: { tabs: true, toolbar: true, popover: options.controls?.popover === true }, verticalBarsOnly: true, }); return { @@ -203,6 +207,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise // Local source overrides can use this even when the startup projection is system. options.buttonDefaultFill ?? null, ...(['tabs', 'toolbar', 'segment', 'fab'] as const).map((component) => !controls || controls[component] === true), + controls?.popover === true, ]); if (active && activeConfiguration !== configuration) return Promise.reject( @@ -235,6 +240,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise (async () => { if (Capacitor.getPlatform() !== 'ios') return fallback('Requires Capacitor iOS'); let runtime: NativeUIShellHandle | undefined; + let overlays: Awaited> | undefined; let metricsListener: Awaited> | undefined; let stopVerticalBarsLayout: (() => void) | undefined; try { @@ -261,10 +267,20 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise native, createVerticalBarsWebProjection(document, options, () => !nativeVerticalBars() || native.getStatus().state === 'stopped'), ); + if (controls?.popover === true) + overlays = await createOverlayController(document, plugin, options, native.retain, nativeVerticalBars); return manage(runtime, { - suspend: () => prehide?.suspend(), + suspend: async () => { + const restorePrehide = prehide?.suspend(); + const resumeOverlays = await overlays?.suspend(); + return () => { + restorePrehide?.(); + resumeOverlays?.(); + }; + }, release, destroy: async () => { + await overlays?.destroy(); await metricsListener?.remove().catch(() => {}); stopVerticalBarsLayout?.(); prehide?.stop(); @@ -272,6 +288,7 @@ export const enableNativeUIShell = (options: NativeUIShellOptions = {}): Promise }, }); } catch (error) { + await overlays?.destroy(); await runtime?.destroy(); await metricsListener?.remove().catch(() => {}); stopVerticalBarsLayout?.(); diff --git a/src/native/overlays/content.ts b/src/native/overlays/content.ts new file mode 100644 index 00000000..bd91e51b --- /dev/null +++ b/src/native/overlays/content.ts @@ -0,0 +1,51 @@ +import { sheetText } from './styles'; + +/** Constructed stylesheets cannot cross documents with their nodes. */ +export const moveContent = (content: HTMLElement[], destination: HTMLElement): (() => void) => { + const home = content.map((element) => ({ element, parent: element.parentNode!, next: element.nextSibling })); + const roots: { root: ShadowRoot; css: string; sheets: CSSStyleSheet[] }[] = []; + const collect = (node: Element) => { + if (node.shadowRoot) { + roots.push({ + root: node.shadowRoot, + css: sheetText(node.shadowRoot.adoptedStyleSheets), + sheets: [...node.shadowRoot.adoptedStyleSheets], + }); + for (const child of Array.from(node.shadowRoot.children)) collect(child); + } + for (const child of Array.from(node.children)) collect(child); + }; + content.forEach(collect); + for (const element of content) destination.append(destination.ownerDocument.adoptNode(element)); + const styles = roots + .filter(({ css }) => css) + .map(({ root, css }) => { + const style = destination.ownerDocument.createElement('style'); + style.textContent = css; + root.append(style); + return style; + }); + return () => { + styles.forEach((style) => style.remove()); + for (const { element, parent, next } of home) { + const connected = element.isConnected; + parent.ownerDocument!.adoptNode(element); + // Ionic may already have destroyed the Angular component on dismiss. + if (connected) parent.insertBefore(element, next?.parentNode === parent ? next : null); + } + for (const { root, css, sheets } of roots) { + // WebKit re-associates adopted sheets with the new document across + // adoptNode, so they may no longer match this root; fall back to the + // inline copy instead of aborting the remaining restore. + try { + root.adoptedStyleSheets = sheets; + } catch { + if (css) { + const style = root.ownerDocument.createElement('style'); + style.textContent = css; + root.append(style); + } + } + } + }; +}; diff --git a/src/native/overlays/controller.ts b/src/native/overlays/controller.ts new file mode 100644 index 00000000..42220d24 --- /dev/null +++ b/src/native/overlays/controller.ts @@ -0,0 +1,322 @@ +import type { NativeUIShellOptions, NativeUIShellPlugin } from '../definitions'; +import { bounded } from '../runtime'; +import { isExcluded, isPermanentlyExcluded, isShellDisabled } from '../shared/dom'; +import { activeElement, trapFocus } from './focus'; +import { relayPopover, popoverPresentation, verticalBarsPopoverAnchor } from './popover'; +import { nativeDialogAnimation } from './dialog-animation'; +import { relayStyles } from './styles'; + +type Presentation = { kind: 'popover'; animated: boolean }; +type Overlay = HTMLIonPopoverElement; +const lifecycle = (overlay: Overlay, phase: string) => + `ion${overlay.localName.slice(4, 5).toUpperCase()}${overlay.localName.slice(5)}${phase}`; + +interface Connection { + id: string; + overlay: Overlay; + dismissed: boolean; + win?: Window; + focus: Element | null; + stopStyles?: () => void; + releaseProjection?: () => void; + stopContent?: () => void; + stopFocus?: () => void; + stopEvents?: () => void; + stopChildEvents?: () => void; + stopExclusions?: () => void; + closing?: Promise; + nativeClosing?: Promise; + stopAnimation?: () => void; + hideAnimation?: () => void; + stopLifecycle?: () => void; +} + +/** Ionic owns lifecycle and dismissal; only one overlay is relayed at a time. */ +export const createOverlayController = async ( + doc: Document, + plugin: NativeUIShellPlugin, + options: NativeUIShellOptions, + retainProjection: (deferActivation?: (id: string) => boolean) => () => void, + nativeVerticalBars: () => boolean, +) => { + let current: Connection | undefined; + let opening = Promise.resolve(); + let sequence = 0; + let stopped = false; + const suspensions = new Set(); + const events = ['Modal', 'Popover', 'Alert', 'ActionSheet', 'Loading', 'Picker', 'Toast'].map((name) => `ion${name}WillPresent`); + const cleanup = async (connection: Connection) => { + // Every teardown step is isolated and bounded: a throwing or hanging step + // must not strand the retained page projections or the child window. + const stage = async (label: string, run?: () => void | Promise) => { + try { + await bounded(Promise.resolve(run?.())); + } catch (error) { + console.error(`Native overlay teardown failed at ${label}; continuing cleanup anyway.`, error); + } + }; + try { + await stage('events', connection.stopEvents); + await stage('lifecycle', connection.stopLifecycle); + await stage('childEvents', connection.stopChildEvents); + await stage('exclusions', connection.stopExclusions); + await stage('focus', connection.stopFocus); + // A timed-out dismissal must not strand the relayed content in the child window. + await connection.nativeClosing?.catch((error) => { + console.error('Native overlay dismissal did not complete; restoring the Web content anyway.', error); + }); + await stage('content', connection.stopContent); + await stage('animation', connection.stopAnimation); + await stage('styles', connection.stopStyles); + await stage('close', () => bounded(plugin.closeOverlay({ id: connection.id }))); + } finally { + // The page projections must be released even if teardown throws above. + connection.releaseProjection?.(); + connection.releaseProjection = undefined; + try { + connection.win?.close(); + } catch (error) { + console.error('Native overlay window was already gone.', error); + } + if (current === connection) current = undefined; + const focus = connection.focus as HTMLElement | null; + if (focus?.isConnected && focus.tabIndex >= 0) focus.focus({ preventScroll: true }); + } + }; + const release = (connection: Connection): Promise => (connection.closing ??= cleanup(connection)); + const restoreWeb = async () => { + if (current) await release(current); + }; + const excluded = (overlay: HTMLElement) => isPermanentlyExcluded(overlay) || isExcluded(overlay) || isShellDisabled(overlay); + const connect = async (connection: Connection, ready: Promise, presentation: Presentation) => { + const { overlay } = connection; + const gone = () => + current !== connection || + stopped || + suspensions.size || + connection.dismissed || + !overlay.isConnected || + !nativeVerticalBars() || + excluded(overlay); + // Keep the source invisible while the native toolbar prepares its popover. + connection.hideAnimation?.(); + try { + // Prepare the child window concurrently with Ionic's content initialization. + const staging = (async () => { + await bounded( + plugin.prepareOverlay({ + id: connection.id, + }), + ); + if (gone()) return undefined; + const win = doc.defaultView!.open(`about:blank#${connection.id}`, '_blank'); + if (!win) throw new Error('Native overlay window unavailable'); + connection.win = win; + // The requested blank page replaces the initial document created by window.open. + // WebKit percent-encodes the '#' marker in the popup URL; compare decoded. + // Adopt nodes only after that navigation; otherwise WebKit discards the relay. + if (decodeURIComponent(win.document.URL) !== `about:blank#${connection.id}` || win.document.readyState !== 'complete') + await bounded(new Promise((resolve) => win.addEventListener('load', () => resolve(), { once: true }))); + if (gone()) return undefined; + // about:blank is a quirks-mode document in WebKit. Initialize standards + // mode before adopting live content: otherwise percentage button heights + // can resolve against the entire popover viewport. + win.document.open(); + win.document.write(''); + win.document.close(); + connection.stopChildEvents = observe(win.document); + const environment = relayStyles(doc, win.document, overlay); + connection.stopStyles = environment.stop; + await bounded(environment.ready); + if (gone()) return undefined; + return { destination: environment.destination, win }; + })(); + // The staging join is awaited below; settle early rejections quietly. + staging.catch(() => {}); + // Ionic initializes its gestures after didPresent. Let that turn finish before adopting their content. + // A stalled presentation must not park the opening queue forever. + await bounded(ready); + // Placement is captured after Ionic has laid out the overlay. + await bounded(new Promise((resolve) => doc.defaultView!.requestAnimationFrame(() => resolve()))); + const staged = await staging; + if (gone() || !staged) { + await release(connection); + return; + } + const focused = activeElement(doc) as HTMLElement | null; + const nativePresentation = popoverPresentation(overlay); + const content = relayPopover(overlay, staged.destination); + connection.stopContent = content.stop; + connection.hideAnimation?.(); + // Ionic does not dismiss a presented overlay when its page is hidden or + // destroyed; without this a popover could retain the page projections + // forever and freeze every later control update. + const exclusions = new MutationObserver(() => { + if (!overlay.isConnected || !nativeVerticalBars() || excluded(overlay) || isShellDisabled(content.root)) + void release(connection).catch(console.error); + }); + const attributes = { attributes: true, attributeFilter: ['class', 'data-shell', 'hidden', 'inert'] }; + for (let node: HTMLElement | null = overlay; node; node = node.parentElement) { + exclusions.observe(node, attributes); + exclusions.observe(node, { childList: true }); + } + exclusions.observe(content.root, { ...attributes, subtree: true }); + connection.stopExclusions = () => exclusions.disconnect(); + connection.stopFocus = trapFocus( + content.root, + () => + !connection.dismissed && + (!('focusTrap' in overlay) || overlay.focusTrap !== false) && + !overlay.classList.contains('ion-disable-focus-trap'), + ); + const close = () => { + void release(connection).catch(console.error); + }; + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); + }; + staged.win.document.addEventListener('keydown', escape); + overlay.addEventListener(lifecycle(overlay, 'DidDismiss'), close, { once: true }); + connection.stopEvents = () => { + staged.win.document.removeEventListener('keydown', escape); + overlay.removeEventListener(lifecycle(overlay, 'DidDismiss'), close); + }; + // SwiftUI owns the only visible presentation animation. + await bounded( + plugin.presentOverlay({ + id: connection.id, + presentation: { ...nativePresentation, animated: presentation.animated }, + }), + ); + (focused?.isConnected && focused.ownerDocument === staged.win.document ? focused : content.root).focus({ preventScroll: true }); + } catch (error) { + await release(connection); + console.error('Native overlay projection failed; keeping Ionic content in the source WebView.', error); + } + }; + const present = (event: Event) => { + const overlay = event.target as Overlay; + if (current) { + const connection = current; + current = undefined; + opening = opening.then(() => release(connection)).catch(console.error); + if (!connection.dismissed) return; + // A didDismiss handler may immediately open the next popover while SwiftUI is still closing this one. + connection.stopAnimation?.(); + connection.stopAnimation = undefined; + } + const enabled = + overlay.localName === 'ion-popover' && + options.controls?.popover === true && + nativeVerticalBars() && + verticalBarsPopoverAnchor(overlay) !== undefined; + if (stopped || suspensions.size || !enabled || excluded(overlay)) { + opening = opening.then(restoreWeb).catch(console.error); + return; + } + const connection: Connection = { id: `ios-theme-overlay-${++sequence}`, overlay, dismissed: false, focus: activeElement(doc) }; + const presentation: Presentation = { kind: 'popover', animated: overlay.animated }; + current = connection; + // Retain synchronously, before the scheduled projection update can retire the rail. + const anchorId = verticalBarsPopoverAnchor(overlay); + connection.releaseProjection = retainProjection( + (id) => + id === anchorId && + current === connection && + !!connection.nativeClosing && + !stopped && + !suspensions.size && + nativeVerticalBars() && + !excluded(overlay), + ); + const closed = () => { + connection.dismissed = true; + }; + overlay.addEventListener(lifecycle(overlay, 'DidDismiss'), closed, { once: true }); + let resolveReady!: () => void; + const readyPromise = new Promise((resolve) => { + resolveReady = resolve; + }); + const ready = () => { + resolveReady(); + }; + const closeNative = () => + (connection.nativeClosing ??= bounded(plugin.dismissOverlay({ id: connection.id, animated: presentation.animated }))); + const animation = nativeDialogAnimation(overlay, closeNative); + connection.stopAnimation = animation.stop; + connection.hideAnimation = animation.hide; + animation.hide(); + overlay.addEventListener(lifecycle(overlay, 'DidPresent'), ready, { once: true }); + connection.stopLifecycle = () => overlay.removeEventListener(lifecycle(overlay, 'DidDismiss'), closed); + opening = opening + .then(() => connect(connection, readyPromise, presentation)) + .catch(console.error) + .finally(() => { + overlay.removeEventListener(lifecycle(overlay, 'DidPresent'), ready); + }); + }; + const menuOpen = (event: Event) => { + if (!event.composedPath().some((node): node is HTMLElement => node instanceof HTMLElement && node.matches('ion-menu'))) return; + // Menus render in the source document below the native overlay; release the relay. + const connection = current; + current = undefined; + if (connection) opening = opening.then(() => release(connection)).catch(console.error); + }; + const observe = (target: Document) => { + for (const name of events) target.addEventListener(name, present); + target.addEventListener('ionWillOpen', menuOpen); + return () => { + for (const name of events) target.removeEventListener(name, present); + target.removeEventListener('ionWillOpen', menuOpen); + }; + }; + const invalidate = () => { + const connection = current; + current = undefined; + if (connection) opening = opening.then(() => release(connection)).catch(console.error); + }; + // Retention must not freeze an obsolete layout or a departed page. + const win = doc.defaultView!; + win.addEventListener('resize', invalidate); + doc.addEventListener('ionViewWillLeave', invalidate); + const visibility = () => { + if (doc.hidden) invalidate(); + }; + doc.addEventListener('visibilitychange', visibility); + const listener = await bounded( + plugin.addListener('overlay', (event) => { + if (event.id !== current?.id) return; + if (event.action === 'dismiss' && current.overlay.backdropDismiss) + void current.overlay.dismiss(undefined, 'backdrop').catch(console.error); + }), + ); + const stopEvents = observe(doc); + return { + async suspend() { + const lease = Symbol(); + suspensions.add(lease); + await opening; + await restoreWeb(); + return () => { + suspensions.delete(lease); + }; + }, + async destroy() { + stopped = true; + stopEvents(); + win.removeEventListener('resize', invalidate); + doc.removeEventListener('ionViewWillLeave', invalidate); + doc.removeEventListener('visibilitychange', visibility); + try { + await opening; + await restoreWeb(); + } finally { + try { + await bounded(plugin.stopOverlays()); + } finally { + await listener.remove(); + } + } + }, + }; +}; diff --git a/src/native/overlays/dialog-animation.ts b/src/native/overlays/dialog-animation.ts new file mode 100644 index 00000000..fcdc5557 --- /dev/null +++ b/src/native/overlays/dialog-animation.ts @@ -0,0 +1,30 @@ +import { createAnimation } from '@ionic/core/components/index.js'; +import { dismissAnimation } from './dismiss-animation'; + +export type Dialog = HTMLIonPopoverElement; + +/** Hide the emptied source surface once the relay hosts the same live content. */ +export const nativeDialogAnimation = (overlay: Dialog, close: () => Promise) => { + const enterAnimation = overlay.enterAnimation; + overlay.enterAnimation = () => createAnimation(); + const leaveAnimation = overlay.leaveAnimation; + const opacity = overlay.style.opacity; + const event = 'ionPopoverWillDismiss'; + const dismiss = () => { + const closing = close(); + overlay.leaveAnimation = () => dismissAnimation(closing); + }; + overlay.addEventListener(event, dismiss); + return { + // Hide the source surface while the native presentation prepares. + hide() { + overlay.style.opacity = '0'; + }, + stop() { + overlay.removeEventListener(event, dismiss); + overlay.enterAnimation = enterAnimation; + overlay.leaveAnimation = leaveAnimation; + overlay.style.opacity = opacity; + }, + }; +}; diff --git a/src/native/overlays/dismiss-animation.ts b/src/native/overlays/dismiss-animation.ts new file mode 100644 index 00000000..90484ec9 --- /dev/null +++ b/src/native/overlays/dismiss-animation.ts @@ -0,0 +1,15 @@ +import { createAnimation } from '@ionic/core/components/index.js'; + +/** Let Ionic finish dismissal after the native presentation has closed. */ +export const dismissAnimation = (closing: Promise) => { + const animation = createAnimation(); + const play = animation.play; + animation.play = async (options) => { + // A native failure must not suppress Ionic's didDismiss event/data. + await closing.catch((error) => { + console.error('Native popover dismissal failed; completing the Ionic lifecycle.', error); + }); + await play(options); + }; + return animation; +}; diff --git a/src/native/overlays/focus.ts b/src/native/overlays/focus.ts new file mode 100644 index 00000000..eca21b35 --- /dev/null +++ b/src/native/overlays/focus.ts @@ -0,0 +1,64 @@ +export const activeElement = (doc: Document): Element | null => { + let element = doc.activeElement; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + return element; +}; + +const focusableElements = (root: HTMLElement): HTMLElement[] => { + const elements: HTMLElement[] = []; + const win = root.ownerDocument.defaultView!; + const collect = (scope: HTMLElement | ShadowRoot) => { + for (const node of Array.from(scope.querySelectorAll('*'))) { + if (node.inert || node.closest('[inert]') || (node as HTMLButtonElement).disabled) continue; + const before = elements.length; + if (node.shadowRoot) collect(node.shadowRoot); + const css = win.getComputedStyle(node); + if ( + elements.length === before && + node.tabIndex >= 0 && + !node.hidden && + css.display !== 'none' && + css.visibility !== 'hidden' && + node.getClientRects().length + ) + elements.push(node); + } + }; + collect(root); + return elements + .map((element, index) => ({ element, index })) + .sort((a, b) => (a.element.tabIndex || Infinity) - (b.element.tabIndex || Infinity) || a.index - b.index) + .map(({ element }) => element); +}; + +export const trapFocus = (root: HTMLElement, enabled: () => boolean): (() => void) => { + const doc = root.ownerDocument; + const previous = root.getAttribute('tabindex'); + root.tabIndex = -1; + let last: Element | null; + const onFocus = (event: FocusEvent) => { + if (!enabled()) return; + if (event.composedPath().includes(root)) { + last = activeElement(doc); + return; + } + const elements = focusableElements(root); + (elements.find((element) => element === last) ?? elements[0] ?? root).focus({ preventScroll: true }); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey || !enabled()) return; + const elements = focusableElements(root); + const index = elements.findIndex((element) => element === activeElement(doc)); + const next = event.shiftKey ? (index <= 0 ? elements.length - 1 : index - 1) : (index + 1) % elements.length; + event.preventDefault(); + (elements[next] ?? root).focus(); + }; + doc.addEventListener('focusin', onFocus, true); + doc.addEventListener('keydown', onKey, true); + return () => { + doc.removeEventListener('focusin', onFocus, true); + doc.removeEventListener('keydown', onKey, true); + if (previous === null) root.removeAttribute('tabindex'); + else root.setAttribute('tabindex', previous); + }; +}; diff --git a/src/native/overlays/popover.ts b/src/native/overlays/popover.ts new file mode 100644 index 00000000..205c731e --- /dev/null +++ b/src/native/overlays/popover.ts @@ -0,0 +1,80 @@ +import type { ShellPopoverPresentation } from '../definitions'; +import { projectionIds } from '../shared/projection-id'; +import { isDark } from '../shared/dom'; +import { moveContent } from './content'; + +export const popoverTrigger = (overlay: HTMLIonPopoverElement): Element | undefined => { + const event = overlay.event as MouseEvent | undefined; + const target = (overlay.trigger ? overlay.ownerDocument.getElementById(overlay.trigger) : event?.target) as Element | undefined; + return target?.closest?.('ion-button,ion-fab-button,ion-item') ?? target; +}; + +/** Only accepted native rail buttons can retain the page projection. */ +export const verticalBarsPopoverAnchor = (overlay: HTMLIonPopoverElement): string | undefined => { + const trigger = popoverTrigger(overlay); + if ( + !trigger?.matches('ion-button[data-native-ui-shell-vertical-bars-member]') || + !trigger.closest('[data-native-ui-shell]') || + (trigger as HTMLIonButtonElement).disabled + ) + return undefined; + return projectionIds.get(trigger); +}; + +export const popoverPresentation = (overlay: HTMLIonPopoverElement): ShellPopoverPresentation => { + const content = overlay.shadowRoot!.querySelector('.popover-content')!; + const rect = content.getBoundingClientRect(); + const anchorId = verticalBarsPopoverAnchor(overlay); + if (!anchorId) throw new Error('Native Vertical Bars anchor unavailable'); + return { + kind: 'popover', + animated: overlay.animated, + dark: isDark(overlay.ownerDocument.defaultView!.getComputedStyle(overlay)), + anchorId, + width: rect.width, + height: rect.height, + backgroundColor: overlay.ownerDocument.defaultView!.getComputedStyle(content).backgroundColor, + backdropDismiss: overlay.backdropDismiss, + }; +}; + +export const relayPopover = (overlay: HTMLIonPopoverElement, destination: HTMLElement) => { + const doc = destination.ownerDocument; + const source = overlay.shadowRoot!.querySelector('.popover-content')!; + const root = doc.createElement('ion-popover'); + for (const attribute of Array.from(overlay.attributes)) root.setAttribute(attribute.name, attribute.value); + Object.assign(root.style, { + position: 'absolute', + inset: '0', + display: 'block', + visibility: 'visible', + opacity: '1', + pointerEvents: 'auto', + }); + root.setAttribute('role', 'dialog'); + root.setAttribute('aria-modal', 'true'); + const shadow = root.attachShadow({ mode: 'open' }); + const shell = doc.createElement('div'); + const css = overlay.ownerDocument.defaultView!.getComputedStyle(source); + Object.assign(shell.style, { height: '100%', overflow: css.overflow, background: css.backgroundColor }); + shell.append(doc.createElement('slot')); + shadow.append(shell); + destination.append(root); + // Components such as ion-select-popover dismiss their closest ion-popover. + root.dismiss = (...args) => overlay.dismiss(...args); + const restore = moveContent(Array.from(source.querySelector('slot')!.assignedElements()) as HTMLElement[], root); + const select = (event: MouseEvent) => { + if (!event.composedPath().includes(shell)) { + if (overlay.backdropDismiss) void overlay.dismiss(undefined, 'backdrop'); + } else if (overlay.dismissOnSelect) void overlay.dismiss(); + }; + root.addEventListener('click', select); + return { + root, + stop() { + root.removeEventListener('click', select); + restore(); + root.remove(); + }, + }; +}; diff --git a/src/native/overlays/styles.ts b/src/native/overlays/styles.ts new file mode 100644 index 00000000..29740318 --- /dev/null +++ b/src/native/overlays/styles.ts @@ -0,0 +1,80 @@ +export const sheetText = (sheets: readonly CSSStyleSheet[]): string => + sheets.flatMap((sheet) => Array.from(sheet.cssRules, (rule) => rule.cssText)).join('\n'); + +export const relayStyles = ( + source: Document, + destination: Document, + overlay: HTMLElement, +): { destination: HTMLElement; ready: Promise; stop: () => void } => { + const base = destination.createElement('base'); + base.href = source.baseURI; + destination.head.append(base); + const copies = new Map(); + const sheets = destination.createElement('style'); + destination.head.append(sheets); + const sync = () => { + sheets.textContent = sheetText(source.adoptedStyleSheets); + const sources = Array.from(source.querySelectorAll('meta[name=viewport],link[rel=stylesheet],style:not([data-overlay-relay-local])')); + for (const [node, copy] of copies) + if (!sources.includes(node)) { + copy.remove(); + copies.delete(node); + } + for (const node of sources) { + let copy = copies.get(node); + if (!copy) { + copy = node.cloneNode(true) as Element; + copies.set(node, copy); + } else { + for (const attribute of Array.from(copy.attributes)) copy.removeAttribute(attribute.name); + for (const attribute of Array.from(node.attributes)) copy.setAttribute(attribute.name, attribute.value); + copy.textContent = node.textContent; + } + destination.head.append(copy); + } + destination.head.append(surface); + }; + const surface = destination.createElement('style'); + surface.textContent = 'html,body{background:transparent!important}'; + const observers: MutationObserver[] = []; + const mirror = (node: Element, target: HTMLElement, flatten = false) => { + const update = () => { + for (const attribute of Array.from(target.attributes)) if (!node.hasAttribute(attribute.name)) target.removeAttribute(attribute.name); + for (const attribute of Array.from(node.attributes)) target.setAttribute(attribute.name, attribute.value); + // The popover content must not inherit the source page's rail layout. + if (target.localName === 'ion-app') target.classList.remove('ios-theme-vertical-bars'); + if (flatten) target.style.setProperty('display', 'contents', 'important'); + }; + update(); + const observer = new MutationObserver(update); + observer.observe(node, { attributes: true }); + observers.push(observer); + }; + mirror(source.documentElement, destination.documentElement); + mirror(source.body, destination.body); + const ancestors: Element[] = []; + for (let node = overlay.parentElement; node && node !== source.body; node = node.parentElement) ancestors.unshift(node); + let parent = destination.body; + for (const node of ancestors) { + const shell = destination.createElement(node.localName); + mirror(node, shell, true); + parent.append(shell); + parent = shell; + } + sync(); + // A copied external stylesheet can still be loading when Ionic's content is + // ready. Do not expose an unstyled first native frame (notably button sizes). + const ready = Promise.all( + Array.from(destination.querySelectorAll('link[rel=stylesheet]'), (link) => { + if (link.sheet) return Promise.resolve(); + return new Promise((resolve, reject) => { + link.addEventListener('load', () => resolve(), { once: true }); + link.addEventListener('error', () => reject(new Error('Popover stylesheet unavailable')), { once: true }); + }); + }), + ).then(() => undefined); + const observer = new MutationObserver(sync); + observer.observe(source.head, { subtree: true, childList: true, characterData: true, attributes: true }); + observers.push(observer); + return { destination: parent, ready, stop: () => observers.forEach((item) => item.disconnect()) }; +}; diff --git a/src/native/runtime.ts b/src/native/runtime.ts index aeb2d7f3..4c406dcb 100644 --- a/src/native/runtime.ts +++ b/src/native/runtime.ts @@ -1,3 +1,4 @@ +import { projectionIds } from './shared/projection-id'; import { inVerticalBarsSurface, topModal, modalUsesVerticalBars, modalVerticalBarFrame } from './shared/modal'; import type { PluginListenerHandle } from '@capacitor/core'; import { LIFECYCLE_WILL_ENTER, LIFECYCLE_WILL_LEAVE, LIFECYCLE_DID_ENTER, LIFECYCLE_DID_LEAVE } from '@ionic/core/components/index.js'; @@ -35,7 +36,7 @@ const verticalBarsMarker = 'data-native-ui-shell-vertical-bars'; const verticalBarsMemberMarker = 'data-native-ui-shell-vertical-bars-member'; // A failed bridge must not leave the source inaccessible indefinitely. -const bounded = (promise: Promise): Promise => +export const bounded = (promise: Promise): Promise => new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Native UI Shell bridge timed out')), 5000); promise.then(resolve, reject).finally(() => clearTimeout(timer)); @@ -47,7 +48,7 @@ export const createRuntime = async ( options: NativeUIShellOptions = {}, nativeVerticalBars: () => boolean = () => true, verticalBarsOnly = false, -): Promise => { +): Promise boolean) => () => void }> => { const win = doc.defaultView!; const icons = createIconRenderer(); const crossfade = createCrossfade(win); @@ -77,6 +78,7 @@ export const createRuntime = async ( let dirty = false; let pending = false; let stopped = false; + const retentions = new Set<{ accepts?: (id: string) => boolean; activation?: ShellActivation }>(); /** True while a tab-switch handoff should skip crossfade. */ let tabSwitchHandoff = false; /** Keep instant updates until this time — DidLeave often precedes the retire sync. */ @@ -108,6 +110,7 @@ export const createRuntime = async ( if (!value) { value = `shell-${++nextId}`; ids.set(element, value); + projectionIds.set(element, value); } return value; }; @@ -303,8 +306,11 @@ export const createRuntime = async ( pending = true; syncToolbarText(doc); handoffInstant = tabSwitchHandoff || handoffAcrossPending || modalPresentHandoff || win.performance.now() < handoffUntil; - modalPresentHandoff = false; try { + // A retained sync performs no update; keep the modal handoff armed so the + // deferred retirement still runs instantly after the overlay releases. + if (retentions.size && !manualSuspensions.size) return; + modalPresentHandoff = false; const size = `${win.innerWidth}:${win.innerHeight}`; // WebKit can resize before Ionic's fixed DOM positions catch up. // Measure on the next frame instead of retiring a valid native search. @@ -326,7 +332,7 @@ export const createRuntime = async ( ); } } - if (stopped || dirty) return; + if (stopped || dirty || (retentions.size && !manualSuspensions.size)) return; const retained = new Set(candidates.flatMap(candidateSources)); const removed = Array.from(sources.keys()).filter((element) => !retained.has(element)); if (removed.length) { @@ -361,7 +367,7 @@ export const createRuntime = async ( updates++; updatingRevision = snapshot.revision; const result = await bounded(plugin.update(snapshot)); - if (stopped) return; + if (stopped || (retentions.size && !manualSuspensions.size)) return; if (result.revision !== snapshot.revision) throw new Error('Native UI Shell revision mismatch'); if (`${win.innerWidth}:${win.innerHeight}` !== size) { lastSnapshot = ''; @@ -637,7 +643,20 @@ export const createRuntime = async ( on(win.visualViewport, 'resize', refreshLayout); on(win.visualViewport, 'scroll'); } - const handle: NativeUIShellHandle = { + const handle: NativeUIShellHandle & { retain: (deferActivation?: (id: string) => boolean) => () => void } = { + // A native overlay covers this surface; keep its controls and Web ownership intact. + retain(deferActivation) { + const retention: { accepts?: (id: string) => boolean; activation?: ShellActivation } = { accepts: deferActivation }; + retentions.add(retention); + return () => { + if (!retentions.delete(retention)) return; + // UIKit may already expose the anchor while Ionic is still dismissing. + // Replay at most one validated input after the live content is restored. + const activation = retention.activation; + if (activation && retention.accepts?.(activation.id)) win.queueMicrotask(() => activate(activation)); + schedule(); + }; + }, getStatus: (): NativeUIShellStatus => ({ state: stopped ? 'stopped' : sources.size ? 'native' : 'web', projected: sources.size, @@ -695,15 +714,16 @@ export const createRuntime = async ( }, }; const activate = (event: ShellActivation) => { - if ( - stopped || - doc.hidden || - event.revision < acceptedRevision || - event.revision > revision || - event.sequence <= lastSequence || - overlayOpen(false, true) - ) + if (stopped || doc.hidden || event.revision < acceptedRevision || event.revision > revision || event.sequence <= lastSequence) return; + if (overlayOpen(false, true)) { + if (!manualSuspensions.size) { + for (const retention of retentions) { + if (retention.accepts?.(event.id) && (!retention.activation || event.sequence > retention.activation.sequence)) + retention.activation = event; + } + } return; + } // Native may send input before update() resolves on the JS bridge. // Revalidate it after ownership is accepted, using the same action path. if (event.revision > acceptedRevision && event.revision === updatingRevision) { diff --git a/src/native/shared/projection-id.ts b/src/native/shared/projection-id.ts new file mode 100644 index 00000000..9da66b67 --- /dev/null +++ b/src/native/shared/projection-id.ts @@ -0,0 +1,2 @@ +/** Internal identity used to anchor overlays to their native control. */ +export const projectionIds = new WeakMap();